main 5041353

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close