history_sidebar_loading_search.go RawHistoryFolder
1package component
2
3import (
4 "github.com/bnema/puregotk/v4/glib"
5 "github.com/bnema/puregotk/v4/gtk"
6
7 "github.com/bnema/dumber/internal/application/dto"
8 "github.com/bnema/dumber/internal/domain/entity"
9)
10
11// =============================================================================
12// Data loading — background goroutine with paging
13// =============================================================================
14
15func (hs *HistorySidebar) startLoadHistory() {
16 hs.mu.Lock()
17 hs.loadGen++
18 gen := hs.loadGen
19 hs.loadStarted = true
20 hs.isLoading = true
21 hs.mu.Unlock()
22
23 // Fetch first page in a background goroutine
24 go hs.fetchPage(0, gen)
25}
26
27// fetchPage fetches a page of history entries in a background goroutine
28// and schedules the UI update on the GTK main thread.
29func (hs *HistorySidebar) fetchPage(offset int, gen uint64) {
30 hs.mu.RLock()
31 uc := hs.historyUC
32 ctx := hs.ctx
33 hs.mu.RUnlock()
34
35 if uc == nil || ctx == nil {
36 // No provider; show empty state
37 cb := glib.SourceFunc(func(uintptr) bool {
38 hs.mu.Lock()
39 if hs.destroyed {
40 hs.mu.Unlock()
41 return false
42 }
43 hs.loadStarted = false
44 hs.isLoading = false
45 hs.loadDone = true
46 hs.hasMore = false
47 hs.mu.Unlock()
48 hs.scheduleRebuild()
49 return false
50 })
51 hs.scheduleIdle(cb)
52 return
53 }
54
55 entries, err := uc.GetRecent(ctx, sidebarPageSize, offset)
56 if err != nil {
57 hs.logger.Error().Err(err).Int("offset", offset).Msg("failed to load history page")
58 }
59
60 if entries == nil {
61 entries = []*entity.HistoryEntry{}
62 }
63
64 hasMore := len(entries) >= sidebarPageSize
65
66 hs.mu.Lock()
67
68 // If a newer load was started since this fetch began, drop stale results.
69 // Must NOT mutate isLoading/loadStarted — they belong to the current
70 // generation set by startLoadHistory or LoadMore.
71 if gen != hs.loadGen {
72 hs.mu.Unlock()
73 return
74 }
75
76 // If search is active, don't update browse state with stale page data
77 // and don't overwrite search results.
78 if hs.currentQuery != "" {
79 hs.isLoading = false
80 hs.loadStarted = false
81 hs.mu.Unlock()
82 return
83 }
84
85 hs.totalLoaded = offset + len(entries)
86 hs.hasMore = hasMore
87 hs.isLoading = false
88 hs.loadStarted = false
89 hs.loadDone = true
90
91 if offset == 0 {
92 // First page: replace all entries
93 hs.allEntries = entries
94 } else {
95 // Subsequent page: append
96 hs.allEntries = append(hs.allEntries, entries...)
97 }
98
99 // Group for display
100 hs.setDisplayGroupsLocked(groupHistoryByDay(hs.allEntries))
101 hs.mu.Unlock()
102
103 // Schedule UI rebuild on GTK main thread
104 cb := glib.SourceFunc(func(uintptr) bool {
105 hs.rebuildList()
106 return false
107 })
108 hs.scheduleIdle(cb)
109}
110
111// LoadMore fetches the next page and appends it to the existing entries.
112func (hs *HistorySidebar) LoadMore() {
113 hs.mu.Lock()
114 if hs.isLoading || !hs.hasMore || hs.destroyed || hs.currentQuery != "" {
115 hs.mu.Unlock()
116 return
117 }
118 hs.isLoading = true
119 offset := hs.totalLoaded
120 gen := hs.loadGen
121 hs.mu.Unlock()
122
123 hs.logger.Debug().Int("offset", offset).Msg("loading more history entries")
124 go hs.fetchPage(offset, gen)
125}
126
127// =============================================================================
128// Scroll-aware load-more: detects when the user reaches the bottom
129// =============================================================================
130
131func (hs *HistorySidebar) setupScrollLoadMore() {
132 if hs.scrolledWin == nil {
133 return
134 }
135
136 vadj := hs.scrolledWin.GetVadjustment()
137 if vadj == nil {
138 return
139 }
140
141 changedCb := func(_ gtk.Adjustment) {
142 hs.mu.RLock()
143 if hs.destroyed || !hs.hasMore || hs.isLoading {
144 hs.mu.RUnlock()
145 return
146 }
147 value := vadj.GetValue()
148 upper := vadj.GetUpper()
149 pageSize := vadj.GetPageSize()
150 hs.mu.RUnlock()
151
152 // Trigger load-more when within 200px of the bottom
153 if pageSize > 0 && value+pageSize >= upper-200.0 {
154 hs.LoadMore()
155 }
156 }
157 hs.retainedCallbacks = append(hs.retainedCallbacks, changedCb)
158 vadj.ConnectValueChanged(&changedCb)
159}
160
161// =============================================================================
162// Scroll/selection preservation
163// =============================================================================
164
165// preserveScrollAndSelection saves the current scroll position and selected row
166// URL before a rebuild. Must be called with hs.mu write lock held.
167func (hs *HistorySidebar) preserveScrollAndSelection() {
168 hs.prevScrollValue = 0
169 hs.prevSelectedURL = ""
170
171 if hs.scrolledWin != nil {
172 if vadj := hs.scrolledWin.GetVadjustment(); vadj != nil {
173 hs.prevScrollValue = vadj.GetValue()
174 }
175 }
176 if hs.listBox != nil {
177 if selected := hs.listBox.GetSelectedRow(); selected != nil {
178 if url := hs.entryURLAtIndex(selected.GetIndex()); url != "" {
179 hs.prevSelectedURL = url
180 }
181 }
182 }
183}
184
185// restoreScrollAndSelection restores the previously saved scroll position and
186// selection after a rebuild. Called on the GTK main thread.
187func (hs *HistorySidebar) restoreScrollAndSelectionInListBox(listBox *gtk.ListBox) {
188 // Restore selection first (changes scroll position)
189 if hs.prevSelectedURL != "" {
190 hs.selectRowByURLInListBox(listBox, hs.prevSelectedURL)
191 }
192
193 // Then restore scroll position if we have one
194 if hs.prevScrollValue > 0 && hs.scrolledWin != nil {
195 if vadj := hs.scrolledWin.GetVadjustment(); vadj != nil {
196 maxVal := vadj.GetUpper() - vadj.GetPageSize()
197 if hs.prevScrollValue > maxVal {
198 hs.prevScrollValue = maxVal
199 }
200 vadj.SetValue(hs.prevScrollValue)
201 }
202 }
203
204 hs.prevScrollValue = 0
205 hs.prevSelectedURL = ""
206}
207
208// getRowURL extracts the URL stored in a list box row.
209func (hs *HistorySidebar) getRowURL(row *gtk.ListBoxRow) string {
210 if row == nil || !row.GetSelectable() {
211 return ""
212 }
213 child := row.GetChild()
214 if child == nil {
215 return ""
216 }
217
218 // The child is the vertical box. Walk children to find our stored URL.
219 // We store the URL directly on the row as data.
220 // Actually, let's use a simpler approach: walk the list box to find the entry.
221 idx := row.GetIndex()
222 hs.mu.RLock()
223 defer hs.mu.RUnlock()
224
225 return hs.entryURLAtIndex(idx)
226}
227
228// entryURLAtIndex returns the URL of the history entry at the given
229// linear list index (including group headers which return "").
230func (hs *HistorySidebar) entryURLAtIndex(index int) string {
231 return newKeyboardNavModelFromRows(hs.displayRows).entryURLAt(index)
232}
233
234// selectRowByURL finds and selects a row whose URL matches.
235func (hs *HistorySidebar) selectRowByURLInListBox(listBox *gtk.ListBox, url string) {
236 if url == "" || listBox == nil {
237 return
238 }
239 for i := 0; ; i++ {
240 row := listBox.GetRowAtIndex(i)
241 if row == nil {
242 break
243 }
244 if !row.GetSelectable() {
245 continue
246 }
247 if hs.getRowURL(row) == url {
248 listBox.SelectRow(row)
249 return
250 }
251 }
252}
253
254// =============================================================================
255// Search / filtering
256// =============================================================================
257
258func (hs *HistorySidebar) setupSearchHandler() {
259 if hs.searchEntry == nil {
260 return
261 }
262
263 changedCb := func(_ gtk.SearchEntry) {
264 hs.onSearchChanged()
265 }
266 hs.retainedCallbacks = append(hs.retainedCallbacks, changedCb)
267 hs.searchEntry.ConnectSearchChanged(&changedCb)
268}
269
270func (hs *HistorySidebar) onSearchChanged() {
271 hs.mu.Lock()
272 if hs.destroyed {
273 hs.mu.Unlock()
274 return
275 }
276 hs.currentQuery = hs.searchEntry.GetText()
277 hs.preserveScrollAndSelection()
278 oldTimer := hs.debounceTimer
279 hs.debounceTimer = 0
280 hs.mu.Unlock()
281
282 if oldTimer != 0 {
283 hs.removeSource(oldTimer)
284 }
285
286 filterCb := glib.SourceFunc(func(uintptr) bool {
287 hs.applyFilter()
288 return false
289 })
290 timerID := hs.addTimeout(sidebarSearchDebounceMs, filterCb)
291
292 hs.mu.Lock()
293 if hs.destroyed {
294 hs.mu.Unlock()
295 if timerID != 0 {
296 hs.removeSource(timerID)
297 }
298 return
299 }
300 hs.debounceTimer = timerID
301 hs.mu.Unlock()
302}
303
304func (hs *HistorySidebar) applyFilter() {
305 hs.mu.Lock()
306 hs.debounceTimer = 0
307 query := hs.currentQuery
308
309 if query == "" {
310 // Empty query: use in-memory browse entries (paged getRecent).
311 // Clear search state and invalidate any in-flight search so a late
312 // search result doesn't overwrite browse state.
313 hs.searchResults = nil
314 hs.searchDone = false
315 hs.searchGen++
316 hs.setDisplayGroupsLocked(nil)
317 if !hs.loadDone {
318 // Browse was never fully loaded (e.g., a search superseded the
319 // initial page fetch). Clear the list, show a loading indicator,
320 // and restart loading history in the background.
321 hs.mu.Unlock()
322 hs.scheduleRebuild() // Shows "Loading history…" while fetch runs
323 hs.startLoadHistory()
324 return
325 }
326 hs.setDisplayGroupsLocked(groupHistoryByDay(hs.allEntries))
327 hs.mu.Unlock()
328 hs.scheduleRebuild()
329 return
330 }
331
332 // Non-empty query: use real FTS search via the provider.
333 // Cancel any stale in-flight search via generation counter.
334 hs.searchGen++
335 gen := hs.searchGen
336 hs.searchDone = false
337 hs.searchResults = nil
338 hs.setDisplayGroupsLocked(nil)
339 hs.mu.Unlock()
340
341 // Clear the list immediately to avoid showing stale browse results
342 // while the search is in flight.
343 hs.scheduleClearList()
344
345 hs.doFTSearch(query, gen)
346}
347
348// doFTSearch runs a history FTS search in a background goroutine and
349// updates the display when results arrive. Stale results (from a superseded
350// search generation) are silently dropped.
351func (hs *HistorySidebar) doFTSearch(query string, gen uint64) {
352 hs.mu.RLock()
353 uc := hs.historyUC
354 ctx := hs.ctx
355 hs.mu.RUnlock()
356
357 if uc == nil || ctx == nil {
358 return
359 }
360
361 go func() {
362 out, err := uc.Search(ctx, dto.HistorySearchInput{Query: query, Limit: sidebarSearchLimit})
363 var entries []*entity.HistoryEntry
364 if out != nil {
365 entries = make([]*entity.HistoryEntry, len(out.Matches))
366 for i, m := range out.Matches {
367 entries[i] = m.Entry
368 }
369 }
370 if err != nil {
371 hs.logger.Error().Err(err).Str("query", query).Msg("history FTS search failed")
372 }
373 if entries == nil {
374 entries = []*entity.HistoryEntry{}
375 }
376
377 // Apply results on the GTK main thread with stale-result protection
378 cb := glib.SourceFunc(func(uintptr) bool {
379 if hs.applySearchResults(entries, gen, err) {
380 hs.scheduleRebuild()
381 }
382 return false
383 })
384 hs.scheduleIdle(cb)
385 }()
386}
387
388// applySearchResults applies search results under the generation guard.
389// Returns true if results were applied (non-stale), false if the generation
390// had moved on and the results were dropped.
391func (hs *HistorySidebar) applySearchResults(entries []*entity.HistoryEntry, gen uint64, err error) bool {
392 hs.mu.Lock()
393 defer hs.mu.Unlock()
394 if hs.destroyed || gen != hs.searchGen {
395 return false
396 }
397 hs.searchResults = entries
398 hs.searchDone = true
399 hs.searchErr = err
400 hs.setDisplayGroupsLocked(groupHistoryByDay(entries))
401 return true
402}
403
404func (hs *HistorySidebar) scheduleIdle(cb glib.SourceFunc) {
405 if hs != nil && hs.idleScheduler != nil {
406 hs.idleScheduler(cb)
407 return
408 }
409 glib.IdleAdd(&cb, 0)
410}
411
412// scheduleClearList clears the list box on the GTK main thread.
413func (hs *HistorySidebar) scheduleClearList() {
414 cb := glib.SourceFunc(func(uintptr) bool {
415 hs.mu.RLock()
416 destroyed := hs.destroyed
417 listBox := hs.listBox
418 hs.mu.RUnlock()
419 if destroyed || listBox == nil {
420 return false
421 }
422 hs.mu.Lock()
423 hs.clearRelativeTimeBindingsLocked()
424 hs.mu.Unlock()
425 listBox.RemoveAll()
426 return false
427 })
428 hs.scheduleIdle(cb)
429}
430
431// scheduleRebuild schedules a list rebuild on the GTK main thread.
432func (hs *HistorySidebar) scheduleRebuild() {
433 cb := glib.SourceFunc(func(uintptr) bool {
434 hs.rebuildList()
435 return false
436 })
437 hs.scheduleIdle(cb)
438}
439
440// setDisplayGroupsLocked updates grouped history and the explicit display-row
441// model. Caller must hold hs.mu for writing.
442func (hs *HistorySidebar) setDisplayGroupsLocked(groups []historyGroup) {
443 hs.groups = groups
444 hs.displayRows = buildHistoryDisplayRows(groups)
445}