main 5041353

history_sidebar_keyboard.go RawHistoryFolder

1package component
2
3import (
4	"github.com/bnema/puregotk/v4/gdk"
5	"github.com/bnema/puregotk/v4/glib"
6	"github.com/bnema/puregotk/v4/gtk"
7
8	"github.com/bnema/dumber/internal/domain/entity"
9)
10
11// =============================================================================
12// Keyboard navigation
13// =============================================================================
14
15func (hs *HistorySidebar) setupKeyboardNavigation() {
16	if hs.outerBox == nil {
17		return
18	}
19
20	// The ListBox already supports Up/Down arrow navigation natively.
21	// We add a PhaseCapture key controller on the outerBox to intercept
22	// keys before the ListBox processes them.
23	keyController := gtk.NewEventControllerKey()
24	if keyController == nil {
25		return
26	}
27	keyController.SetPropagationPhase(gtk.PhaseCaptureValue)
28
29	keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool {
30		switch keyval {
31		// --- Escape: clear search or close sidebar ---
32		case uint(gdk.KEY_Escape):
33			if hs.searchEntry != nil && hs.searchEntry.GetText() != "" {
34				hs.searchEntry.SetText("")
35				return true
36			}
37			// Close sidebar explicitly and restore focus
38			hs.closeSidebar()
39			return true
40
41		// --- Enter variants ---
42		case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
43			return hs.handleEnterKey(state)
44
45		// --- Delete: remove selected entry ---
46		case uint(gdk.KEY_Delete), uint(gdk.KEY_KP_Delete):
47			if hs.searchEntry != nil && hs.searchEntry.GetText() != "" {
48				return false
49			}
50			return hs.handleDeleteKey()
51
52		// --- PageUp / PageDown: scroll by page ---
53		case uint(gdk.KEY_Page_Up):
54			hs.scrollByPage(-1)
55			return true
56		case uint(gdk.KEY_Page_Down):
57			hs.scrollByPage(1)
58			return true
59
60		// --- Home / End: jump to first/last selectable row ---
61		case uint(gdk.KEY_Home):
62			hs.jumpToFirstSelectable()
63			return true
64		case uint(gdk.KEY_End):
65			hs.jumpToLastSelectable()
66			return true
67
68		// --- Up / Down: previous / next selectable row ---
69		// Ctrl+Up / Ctrl+Down: previous / next day group jump ---
70		case uint(gdk.KEY_Up):
71			if state&gdk.ControlMaskValue != 0 {
72				hs.jumpToPreviousDay()
73				return true
74			}
75			hs.selectPreviousRow()
76			return true
77		case uint(gdk.KEY_Down):
78			if state&gdk.ControlMaskValue != 0 {
79				hs.jumpToNextDay()
80				return true
81			}
82			hs.selectNextRow()
83			return true
84		}
85
86		return false
87	}
88
89	hs.retainedCallbacks = append(hs.retainedCallbacks, keyPressedCb)
90	keyController.ConnectKeyPressed(&keyPressedCb)
91
92	hs.outerBox.AddController(&keyController.EventController)
93}
94
95// handleEnterKey processes Enter, Ctrl+Enter, and Shift+Enter on a selected row.
96// Returns true if the key was consumed.
97func (hs *HistorySidebar) handleEnterKey(state gdk.ModifierType) bool {
98	// Determine activation mode from modifiers
99	var action HistorySidebarKeyboardAction
100
101	switch {
102	case state&gdk.ControlMaskValue != 0:
103		// Ctrl+Enter: navigate but keep sidebar open
104		action = SidebarActionKeepOpenOnActivate
105	case state&gdk.ShiftMaskValue != 0:
106		// Shift+Enter: navigate in new pane
107		action = SidebarActionNewPaneOnActivate
108	default:
109		// Plain Enter: navigate using the default activation behavior.
110		action = SidebarActionCloseOnActivate
111	}
112
113	// Find the selected row and its URL
114	row := hs.listBox.GetSelectedRow()
115	if row == nil || !row.GetSelectable() {
116		return false
117	}
118
119	hs.mu.RLock()
120	url := hs.entryURLAtIndex(row.GetIndex())
121	hs.mu.RUnlock()
122	if url == "" {
123		return false
124	}
125
126	// Schedule activation on the GTK main thread
127	switch action {
128	case SidebarActionKeepOpenOnActivate:
129		hs.navigateWithoutClosing(url)
130	case SidebarActionNewPaneOnActivate:
131		hs.navigateToNewPane(url)
132	default:
133		hs.navigateToURL(url)
134	}
135
136	// Consume the key event
137	return true
138}
139
140// handleDeleteKey removes the selected history entry and updates the selection.
141// Returns true if the key was consumed.
142func (hs *HistorySidebar) handleDeleteKey() bool {
143	row := hs.listBox.GetSelectedRow()
144	if row == nil || !row.GetSelectable() {
145		return false
146	}
147	if hs.historyUC == nil {
148		return false
149	}
150
151	idx := row.GetIndex()
152
153	hs.mu.RLock()
154	url := hs.entryURLAtIndex(idx)
155	entryID := hs.findEntryIDByIndex(idx)
156	nextSelectedURL := ""
157	if nextRow := hs.findNextSelectableAfter(idx); nextRow != -1 {
158		nextSelectedURL = hs.entryURLAtIndex(nextRow)
159	}
160	hs.mu.RUnlock()
161
162	if url == "" || entryID <= 0 {
163		return false
164	}
165
166	go func() {
167		if err := hs.historyUC.Delete(hs.ctx, entryID); err != nil {
168			hs.logger.Error().Err(err).Int64("entry_id", entryID).Msg("failed to delete history entry")
169			return
170		}
171
172		cb := glib.SourceFunc(func(uintptr) bool {
173			hs.mu.Lock()
174			if hs.destroyed {
175				hs.mu.Unlock()
176				return false
177			}
178			hs.applyDeletedEntryLocked(entryID, nextSelectedURL)
179			hs.mu.Unlock()
180
181			hs.rebuildList()
182			return false
183		})
184		hs.scheduleIdle(cb)
185	}()
186
187	return true
188}
189
190// applyDeletedEntryLocked updates local sidebar state after a successful
191// history delete. Must be called with hs.mu write lock held.
192func (hs *HistorySidebar) applyDeletedEntryLocked(entryID int64, nextSelectedURL string) {
193	hs.preserveScrollAndSelection()
194	hs.prevSelectedURL = nextSelectedURL
195	hs.searchGen++
196	hs.loadGen++
197	hs.isLoading = false
198	hs.loadStarted = false
199	hs.removeFromAllEntries(entryID)
200	hs.totalLoaded = len(hs.allEntries)
201	hs.removeFromSearchResults(entryID)
202	hs.rebuildLocalGroups()
203}
204
205// findEntryIDByIndex returns the entry ID for the linear ListBox index.
206// Must be called with hs.mu read lock held.
207func (hs *HistorySidebar) findEntryIDByIndex(index int) int64 {
208	entry := newKeyboardNavModelFromRows(hs.displayRows).entryAt(index)
209	if entry == nil {
210		return 0
211	}
212	return entry.ID
213}
214
215// rebuildLocalGroups rebuilds hs.groups from the current allEntries and query.
216// Must be called with hs.mu write lock held.
217func (hs *HistorySidebar) rebuildLocalGroups() {
218	if hs.currentQuery == "" {
219		hs.setDisplayGroupsLocked(groupHistoryByDay(hs.allEntries))
220	} else if hs.searchResults != nil {
221		hs.setDisplayGroupsLocked(groupHistoryByDay(hs.searchResults))
222	} else {
223		// For search mode, the search results are handled by doFTSearch.
224		// Removing an entry while in search mode would need a re-search.
225		// Fall back to grouping searchResults if they exist.
226		hs.setDisplayGroupsLocked(nil)
227	}
228}
229
230// removeFromAllEntries removes all history entries matching the given URL or ID
231// from hs.allEntries. Must be called with hs.mu write lock held.
232func (hs *HistorySidebar) removeFromAllEntries(id int64) {
233	filtered := make([]*entity.HistoryEntry, 0, len(hs.allEntries))
234	for _, e := range hs.allEntries {
235		if e != nil && e.ID == id {
236			continue
237		}
238		filtered = append(filtered, e)
239	}
240	hs.allEntries = filtered
241}
242
243// removeFromSearchResults removes all history entries matching the given ID
244// from hs.searchResults. Must be called with hs.mu write lock held.
245func (hs *HistorySidebar) removeFromSearchResults(id int64) {
246	if hs.searchResults == nil {
247		return
248	}
249	filtered := make([]*entity.HistoryEntry, 0, len(hs.searchResults))
250	for _, e := range hs.searchResults {
251		if e != nil && e.ID == id {
252			continue
253		}
254		filtered = append(filtered, e)
255	}
256	hs.searchResults = filtered
257}
258
259// findNextSelectableAfter returns the ListBox index of the next selectable
260// row after the given index, falling back to the previous selectable row.
261// Must be called with hs.mu read lock held.
262func (hs *HistorySidebar) findNextSelectableAfter(idx int) int {
263	model := newKeyboardNavModelFromRows(hs.displayRows)
264	if next := model.nextSelectableIndex(idx, +1); next != -1 {
265		return next
266	}
267	if prev := model.nextSelectableIndex(idx, -1); prev != -1 {
268		return prev
269	}
270	return -1
271}
272
273// scrollByPage scrolls the list by one page up or down,
274// keeping the selection visible.
275func (hs *HistorySidebar) scrollByPage(direction int) {
276	if hs.scrolledWin == nil {
277		return
278	}
279	vadj := hs.scrolledWin.GetVadjustment()
280	if vadj == nil {
281		return
282	}
283	pageSize := vadj.GetPageSize()
284	current := vadj.GetValue()
285	newVal := current + float64(direction)*(pageSize*0.9) // 90% page for overlap
286	if newVal < 0 {
287		newVal = 0
288	}
289	upper := vadj.GetUpper() - pageSize
290	if newVal > upper {
291		newVal = upper
292	}
293	if newVal >= 0 {
294		vadj.SetValue(newVal)
295	}
296}
297
298// jumpToPreviousDay selects the first entry in the previous day group
299// relative to the currently selected row.
300func (hs *HistorySidebar) jumpToPreviousDay() {
301	currentIdx := -1
302	if row := hs.listBox.GetSelectedRow(); row != nil {
303		currentIdx = row.GetIndex()
304	}
305
306	hs.mu.RLock()
307	targetIdx := newKeyboardNavModelFromRows(hs.displayRows).previousDayBoundary(currentIdx)
308	hs.mu.RUnlock()
309	if targetIdx == -1 {
310		hs.jumpToFirstSelectable()
311		return
312	}
313	if row := hs.listBox.GetRowAtIndex(targetIdx); row != nil && row.GetSelectable() {
314		hs.listBox.SelectRow(row)
315		hs.scrollRowIntoView(targetIdx)
316		return
317	}
318	hs.jumpToFirstSelectable()
319}
320
321// jumpToNextDay selects the first entry in the next day group
322// relative to the currently selected row.
323func (hs *HistorySidebar) jumpToNextDay() {
324	currentIdx := -1
325	if row := hs.listBox.GetSelectedRow(); row != nil {
326		currentIdx = row.GetIndex()
327	}
328
329	hs.mu.RLock()
330	targetIdx := newKeyboardNavModelFromRows(hs.displayRows).nextDayBoundary(currentIdx)
331	hs.mu.RUnlock()
332	if targetIdx == -1 {
333		hs.jumpToLastSelectable()
334		return
335	}
336	if row := hs.listBox.GetRowAtIndex(targetIdx); row != nil && row.GetSelectable() {
337		hs.listBox.SelectRow(row)
338		hs.scrollRowIntoView(targetIdx)
339		return
340	}
341	hs.jumpToLastSelectable()
342}
343
344// scrollRowIntoView scrolls the scrolled window to ensure the row at
345// the given ListBox index is visible.
346func (hs *HistorySidebar) scrollRowIntoView(index int) {
347	hs.ensureRowVisible(index)
348}
349
350// jumpToFirstSelectable selects the first selectable row in the list.
351func (hs *HistorySidebar) jumpToFirstSelectable() {
352	for i := 0; ; i++ {
353		row := hs.listBox.GetRowAtIndex(i)
354		if row == nil {
355			break
356		}
357		if row.GetSelectable() {
358			hs.listBox.SelectRow(row)
359			hs.ensureRowVisible(i)
360			return
361		}
362	}
363}
364
365// jumpToLastSelectable selects the last selectable row in the list.
366func (hs *HistorySidebar) jumpToLastSelectable() {
367	// Walk backwards through the rows
368	maxIdx := 0
369	var lastRow *gtk.ListBoxRow
370	for i := 0; ; i++ {
371		row := hs.listBox.GetRowAtIndex(i)
372		if row == nil {
373			break
374		}
375		maxIdx = i
376		if row.GetSelectable() {
377			lastRow = row
378		}
379	}
380	// GetRowAtIndex returns nil once the index is past the end of the list,
381	// so this scan terminates naturally at the first missing row.
382	// If we found a selectable row, try it. Otherwise fall back to last row.
383	if lastRow != nil {
384		hs.listBox.SelectRow(lastRow)
385		hs.ensureRowVisible(lastRow.GetIndex())
386		return
387	}
388	// Fallback: last row regardless of selectability
389	if maxIdx > 0 {
390		if row := hs.listBox.GetRowAtIndex(maxIdx); row != nil {
391			hs.listBox.SelectRow(row)
392			hs.ensureRowVisible(maxIdx)
393		}
394	}
395}
396
397// =============================================================================
398// Up/Down row selection (with search entry focus preserved)
399// =============================================================================
400
401// selectPreviousRow selects the previous selectable row (skipping headers).
402// Focus remains in the search entry; the ListBox selection is updated
403// programmatically and scrolled into view.
404func (hs *HistorySidebar) selectPreviousRow() {
405	hs.selectAdjacentRow(-1)
406}
407
408// selectNextRow selects the next selectable row (skipping headers).
409// Focus remains in the search entry; the ListBox selection is updated
410// programmatically and scrolled into view.
411func (hs *HistorySidebar) selectNextRow() {
412	hs.selectAdjacentRow(1)
413}
414
415// selectAdjacentRow moves selection by direction (-1 or +1) to the next
416// selectable row, skipping non-selectable (header) rows. If nothing is
417// currently selected, it selects the first (down) or last (up) selectable
418// row. Focus remains in the search entry.
419func (hs *HistorySidebar) selectAdjacentRow(direction int) {
420	if hs.listBox == nil {
421		return
422	}
423
424	current := -1
425	if row := hs.listBox.GetSelectedRow(); row != nil {
426		current = row.GetIndex()
427	}
428
429	hs.mu.RLock()
430	model := newKeyboardNavModelFromRows(hs.displayRows)
431	var target int
432	if current < 0 {
433		if direction > 0 {
434			target = model.firstSelectableIndex()
435		} else {
436			target = model.lastSelectableIndex()
437		}
438	} else {
439		target = model.nextSelectableIndex(current, direction)
440	}
441	hs.mu.RUnlock()
442
443	if target == -1 {
444		return
445	}
446	if row := hs.listBox.GetRowAtIndex(target); row != nil && row.GetSelectable() {
447		hs.listBox.SelectRow(row)
448		hs.ensureRowVisible(target)
449	}
450}
451
452// ensureRowVisible adjusts the scrolled window so the row at index is
453// visible, WITHOUT calling GrabFocus (preserving search entry focus).
454// The Y position is computed by summing the allocated heights of all
455// preceding rows.
456func (hs *HistorySidebar) ensureRowVisible(index int) {
457	if hs.scrolledWin == nil || hs.listBox == nil {
458		return
459	}
460	vadj := hs.scrolledWin.GetVadjustment()
461	if vadj == nil {
462		return
463	}
464	row := hs.listBox.GetRowAtIndex(index)
465	if row == nil {
466		return
467	}
468
469	// Sum allocated heights of all preceding rows to estimate Y position.
470	var yPos int
471	for i := range index {
472		r := hs.listBox.GetRowAtIndex(i)
473		if r == nil {
474			continue
475		}
476		yPos += r.GetAllocatedHeight()
477	}
478
479	rowHeight := row.GetAllocatedHeight()
480	if rowHeight <= 0 {
481		return
482	}
483
484	pageSize := vadj.GetPageSize()
485	current := vadj.GetValue()
486	rowTop := float64(yPos)
487	rowBottom := rowTop + float64(rowHeight)
488
489	if rowTop < current {
490		// Row is above the visible area — scroll up.
491		vadj.SetValue(rowTop)
492	} else if rowBottom > current+pageSize {
493		// Row is below the visible area — scroll down.
494		vadj.SetValue(rowBottom - pageSize)
495	}
496}
497
498// =============================================================================
499// Row activation (Enter / click)
500// =============================================================================
501
502func (hs *HistorySidebar) onRowActivated(row *gtk.ListBoxRow) {
503	if row == nil || !row.GetSelectable() {
504		return
505	}
506
507	hs.mu.RLock()
508	// Allow activation when browse is loaded or when search results are available.
509	// This prevents a race where the user searches before the initial browse page
510	// finishes loading — browse may be left unloaded, but search results should
511	// still be activatable.
512	hasSearchResults := hs.searchDone && hs.searchResults != nil
513	if (!hs.loadDone && !hasSearchResults) || len(hs.groups) == 0 {
514		hs.mu.RUnlock()
515		return
516	}
517	entry := newKeyboardNavModelFromRows(hs.displayRows).entryAt(row.GetIndex())
518	hs.mu.RUnlock()
519	if entry == nil || entry.URL == "" {
520		return
521	}
522
523	hs.navigateToURL(entry.URL)
524}
525
526func (hs *HistorySidebar) navigateToURL(url string) {
527	if hs.onURL == nil || url == "" {
528		return
529	}
530
531	navigateCb := glib.SourceFunc(func(uintptr) bool {
532		hs.mu.RLock()
533		destroyed := hs.destroyed
534		hs.mu.RUnlock()
535		if destroyed {
536			return false
537		}
538		hs.onURL(hs.ctx, url)
539		return false
540	})
541	hs.scheduleIdle(navigateCb)
542}
543
544// navigateWithoutClosing navigates to the URL but does NOT close the sidebar.
545// Used by Ctrl+Enter activation.
546func (hs *HistorySidebar) navigateWithoutClosing(url string) {
547	if hs.onNavigateKeepOpen == nil || url == "" {
548		return
549	}
550	hs.doNavigateWithoutClose(url)
551}
552
553// doNavigateWithoutClose schedules navigation without closing the sidebar.
554// Uses the dedicated OnNavigateKeepOpen path so hosts can override the
555// default activation behavior when they need a distinct keep-open action.
556func (hs *HistorySidebar) doNavigateWithoutClose(url string) {
557	navigateCb := glib.SourceFunc(func(uintptr) bool {
558		hs.mu.RLock()
559		destroyed := hs.destroyed
560		hs.mu.RUnlock()
561		if destroyed {
562			return false
563		}
564		hs.onNavigateKeepOpen(hs.ctx, url)
565		return false
566	})
567	hs.scheduleIdle(navigateCb)
568}
569
570// navigateToNewPane navigates to the URL by opening it in a new pane.
571// The sidebar stays open. Used by Shift+Enter activation.
572func (hs *HistorySidebar) navigateToNewPane(url string) {
573	if hs.onOpenInNewPane == nil || url == "" {
574		return
575	}
576
577	navigateCb := glib.SourceFunc(func(uintptr) bool {
578		hs.mu.RLock()
579		destroyed := hs.destroyed
580		hs.mu.RUnlock()
581		if destroyed {
582			return false
583		}
584		if err := hs.onOpenInNewPane(hs.ctx, url); err != nil {
585			hs.logger.Error().Err(err).Str("url", url).Msg("history sidebar new-pane navigation failed")
586		}
587		return false
588	})
589	hs.scheduleIdle(navigateCb)
590}
591
592// closeSidebar calls the configured OnClose callback to tell the host to
593// hide the sidebar and restore focus to the active content pane/webview.
594func (hs *HistorySidebar) closeSidebar() {
595	if hs.onClose != nil {
596		hs.onClose()
597	}
598}

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