v0.20.0 c741a39

omnibox.go RawHistoryFolder

1package component
2
3import (
4	"context"
5	"fmt"
6	"strings"
7	"sync"
8	"time"
9
10	"github.com/bnema/dumber/internal/application/usecase"
11	"github.com/bnema/dumber/internal/domain/entity"
12	"github.com/bnema/dumber/internal/domain/url"
13	"github.com/bnema/dumber/internal/infrastructure/config"
14	"github.com/bnema/dumber/internal/logging"
15	"github.com/bnema/dumber/internal/ui/adapter"
16	"github.com/bnema/dumber/internal/ui/layout"
17	"github.com/jwijenbergh/puregotk/v4/gdk"
18	"github.com/jwijenbergh/puregotk/v4/glib"
19	"github.com/jwijenbergh/puregotk/v4/gtk"
20)
21
22const (
23	omniboxWidthPct       = 0.8 // 80% of parent window width
24	omniboxMaxWidth       = 800 // Maximum width in pixels
25	omniboxFallbackHeight = 600 // Fallback height when overlay not yet allocated
26	omniboxHeightPct      = 0.6 // 60% of parent window height
27	omniboxMaxResults     = 10
28	omniboxTopMarginPct   = 0.2 // 20% from top
29	debounceDelayMs       = 150
30)
31
32// ViewMode distinguishes history search from favorites display.
33type ViewMode string
34
35const (
36	ViewModeHistory   ViewMode = "history"
37	ViewModeFavorites ViewMode = "favorites"
38)
39
40// Suggestion represents a search result from history.
41type Suggestion struct {
42	URL        string
43	Title      string
44	FaviconURL string
45}
46
47// Favorite represents a bookmarked URL.
48type Favorite struct {
49	ID         int64
50	URL        string
51	Title      string
52	FaviconURL string
53	Position   int
54}
55
56// Omnibox is the native GTK4 address bar / command palette.
57type Omnibox struct {
58	// GTK widgets
59	outerBox     *gtk.Box // Outer container for positioning
60	mainBox      *gtk.Box // Main content box
61	headerBox    *gtk.Box
62	historyBtn   *gtk.Button
63	favoritesBtn *gtk.Button
64	zoomLabel    *gtk.Label
65	entry        *gtk.SearchEntry
66	scrolledWin  *gtk.ScrolledWindow
67	listBox      *gtk.ListBox
68
69	// Parent overlay reference for sizing (set via SetParentOverlay)
70	parentOverlay layout.OverlayWidget
71
72	// State
73	mu            sync.RWMutex
74	visible       bool
75	viewMode      ViewMode
76	selectedIndex int
77	suggestions   []Suggestion
78	favorites     []Favorite
79	hasNavigated  bool // true if user navigated with arrow keys (enables space to toggle favorite)
80
81	// Dependencies
82	historyUC       *usecase.SearchHistoryUseCase
83	favoritesUC     *usecase.ManageFavoritesUseCase
84	faviconAdapter  *adapter.FaviconAdapter
85	copyURLUC       *usecase.CopyURLUseCase
86	shortcuts       map[string]config.SearchShortcut
87	defaultSearch   string
88	initialBehavior string
89	ctx             context.Context
90
91	// Callbacks
92	onNavigate func(url string)
93	onClose    func()
94	onToast    func(message string)
95
96	// Debouncing
97	debounceTimer *time.Timer
98	debounceMu    sync.Mutex
99	lastQuery     string // Prevent duplicate searches
100
101	// Scaling
102	uiScale float64
103
104	// Cached measurements (populated after first layout)
105	measuredHeights struct {
106		header    int  // headerBox natural height
107		entry     int  // entry natural height
108		singleRow int  // single ListBoxRow natural height
109		valid     bool // whether cache is valid
110	}
111
112	// Callback retention: keep GTK signal callbacks reachable by Go GC.
113	retainedCallbacks []interface{}
114}
115
116// OmniboxConfig holds configuration for creating an Omnibox.
117type OmniboxConfig struct {
118	HistoryUC       *usecase.SearchHistoryUseCase
119	FavoritesUC     *usecase.ManageFavoritesUseCase
120	FaviconAdapter  *adapter.FaviconAdapter
121	CopyURLUC       *usecase.CopyURLUseCase
122	Shortcuts       map[string]config.SearchShortcut
123	DefaultSearch   string
124	InitialBehavior string
125	UIScale         float64              // UI scale for favicon sizing
126	OnNavigate      func(url string)     // Callback when user navigates via omnibox
127	OnToast         func(message string) // Callback to show toast notification
128}
129
130// NewOmnibox creates a new native GTK4 omnibox widget.
131// Call SetParentOverlay() before Show() to set the parent for sizing.
132func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
133	log := logging.FromContext(ctx)
134
135	uiScale := cfg.UIScale
136	if uiScale <= 0 {
137		uiScale = 1.0
138	}
139
140	o := &Omnibox{
141		viewMode:        ViewModeHistory,
142		selectedIndex:   -1,
143		historyUC:       cfg.HistoryUC,
144		favoritesUC:     cfg.FavoritesUC,
145		faviconAdapter:  cfg.FaviconAdapter,
146		copyURLUC:       cfg.CopyURLUC,
147		shortcuts:       cfg.Shortcuts,
148		defaultSearch:   cfg.DefaultSearch,
149		initialBehavior: cfg.InitialBehavior,
150		onToast:         cfg.OnToast,
151		ctx:             ctx,
152		uiScale:         uiScale,
153	}
154
155	if err := o.createWidgets(); err != nil {
156		log.Error().Err(err).Msg("failed to create omnibox widgets")
157		return nil
158	}
159
160	o.setupKeyboardHandling()
161	o.setupEntryChanged()
162
163	// Set navigation callback if provided
164	if cfg.OnNavigate != nil {
165		o.onNavigate = cfg.OnNavigate
166	}
167
168	log.Debug().Msg("omnibox created")
169	return o
170}
171
172// SetParentOverlay sets the overlay widget used for sizing calculations.
173// Must be called before Show().
174func (o *Omnibox) SetParentOverlay(overlay layout.OverlayWidget) {
175	o.mu.Lock()
176	defer o.mu.Unlock()
177	o.parentOverlay = overlay
178}
179
180// WidgetAsLayout returns the omnibox's outer widget as a layout.Widget.
181// This is useful for adding the omnibox to a PaneView overlay.
182func (o *Omnibox) WidgetAsLayout(factory layout.WidgetFactory) layout.Widget {
183	if o.outerBox == nil {
184		return nil
185	}
186	return factory.WrapWidget(&o.outerBox.Widget)
187}
188
189// resizeAndCenter adjusts the omnibox size based on content and centers it.
190// rowCount is the number of result rows to display (0 = no content, max 10).
191func (o *Omnibox) resizeAndCenter(rowCount int) {
192	if o.parentOverlay == nil || o.outerBox == nil || o.mainBox == nil {
193		return
194	}
195
196	// Use GetAllocatedWidth for actual rendered size (height not needed here)
197	parentWidth := o.parentOverlay.GetAllocatedWidth()
198	if parentWidth <= 0 {
199		parentWidth = omniboxMaxWidth
200	}
201
202	width := int(float64(parentWidth) * omniboxWidthPct)
203	if width > omniboxMaxWidth {
204		width = omniboxMaxWidth
205	}
206
207	// Cap at max results
208	if rowCount > omniboxMaxResults {
209		rowCount = omniboxMaxResults
210	}
211
212	// Schedule measurement after GTK has laid out widgets
213	// This ensures we get accurate heights from actual rendered content
214	var cb glib.SourceFunc = func(uintptr) bool {
215		o.measureAndResize(width, rowCount)
216		return false // One-shot
217	}
218	glib.IdleAdd(&cb, 0)
219}
220
221// measureAndResize calculates and sets the omnibox height based on row count.
222// Uses measured widget heights when available, falls back to estimates.
223func (o *Omnibox) measureAndResize(width, rowCount int) {
224	if o.outerBox == nil || o.mainBox == nil {
225		return
226	}
227
228	log := logging.FromContext(o.ctx)
229
230	var rowHeight int
231
232	if o.measuredHeights.valid && o.measuredHeights.singleRow > 0 {
233		// Use measured values from GTK4 Measure API
234		rowHeight = o.measuredHeights.singleRow
235	} else {
236		// Fallback to hardcoded estimate (before first measurement)
237		const fallbackRowHeight = 60
238		rowHeight = int(fallbackRowHeight * o.uiScale)
239	}
240
241	// Calculate content height for the scrolled window
242	// This is the key to dynamic sizing - limit the ScrolledWindow's max height
243	contentHeight := 0
244	if rowCount > 0 {
245		contentHeight = rowCount * rowHeight
246	}
247
248	// Cap at max results
249	maxContentHeight := omniboxMaxResults * rowHeight
250	if contentHeight > maxContentHeight {
251		contentHeight = maxContentHeight
252	}
253
254	// Set size constraints on ScrolledWindow to control dynamic sizing
255	if o.scrolledWin != nil {
256		// Reset min first to avoid assertion failure when shrinking
257		// GTK requires min <= max, so we reset min before setting new values
258		o.scrolledWin.SetMinContentHeight(-1)
259		o.scrolledWin.SetMaxContentHeight(contentHeight)
260		o.scrolledWin.SetMinContentHeight(contentHeight)
261	}
262
263	// Force layout recalculation
264	o.outerBox.QueueResize()
265
266	log.Debug().
267		Int("width", width).
268		Int("contentHeight", contentHeight).
269		Int("rowHeight", rowHeight).
270		Int("rows", rowCount).
271		Bool("measured", o.measuredHeights.valid).
272		Msg("omnibox resized")
273}
274
275// createWidgets builds the GTK widget hierarchy.
276func (o *Omnibox) createWidgets() error {
277	if err := o.initOuterBox(); err != nil {
278		return err
279	}
280	if err := o.initMainBox(); err != nil {
281		return err
282	}
283	if err := o.initHeader(); err != nil {
284		return err
285	}
286	if err := o.initEntry(); err != nil {
287		return err
288	}
289	if err := o.initList(); err != nil {
290		return err
291	}
292	o.assembleWidgets()
293	return nil
294}
295
296// setupKeyboardHandling adds keyboard event handling.
297func (o *Omnibox) setupKeyboardHandling() {
298	log := logging.FromContext(o.ctx)
299	controller := gtk.NewEventControllerKey()
300	if controller == nil {
301		log.Error().Msg("failed to create event controller key")
302		return
303	}
304
305	// Set capture phase to intercept before entry
306	controller.SetPropagationPhase(gtk.PhaseCaptureValue)
307
308	keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool {
309		return o.handleKeyPress(keyval, state)
310	}
311	o.retainedCallbacks = append(o.retainedCallbacks, keyPressedCb)
312	controller.ConnectKeyPressed(&keyPressedCb)
313
314	o.outerBox.AddController(&controller.EventController)
315}
316
317func (o *Omnibox) initOuterBox() error {
318	o.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
319	if o.outerBox == nil {
320		return errNilWidget("outerBox")
321	}
322	o.outerBox.AddCssClass("omnibox-outer")
323	o.outerBox.SetHalign(gtk.AlignCenterValue) // Center horizontally
324	o.outerBox.SetValign(gtk.AlignStartValue)  // Align to top
325	o.outerBox.SetVisible(false)               // Hidden by default
326	return nil
327}
328
329func (o *Omnibox) initMainBox() error {
330	o.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
331	if o.mainBox == nil {
332		return errNilWidget("mainBox")
333	}
334	o.mainBox.AddCssClass("omnibox-container")
335	return nil
336}
337
338func (o *Omnibox) initHeader() error {
339	o.headerBox = gtk.NewBox(gtk.OrientationHorizontalValue, 0)
340	if o.headerBox == nil {
341		return errNilWidget("headerBox")
342	}
343	o.headerBox.AddCssClass("omnibox-header")
344	o.headerBox.SetHexpand(true)
345
346	o.historyBtn = gtk.NewButtonWithLabel("History")
347	if o.historyBtn == nil {
348		return errNilWidget("historyBtn")
349	}
350	o.historyBtn.AddCssClass("omnibox-header-btn")
351	o.historyBtn.AddCssClass("omnibox-header-active")
352	o.historyBtn.SetCanFocus(false)
353
354	o.favoritesBtn = gtk.NewButtonWithLabel("Favorites")
355	if o.favoritesBtn == nil {
356		return errNilWidget("favoritesBtn")
357	}
358	o.favoritesBtn.AddCssClass("omnibox-header-btn")
359	o.favoritesBtn.SetCanFocus(false)
360
361	historyClickCb := func(_ gtk.Button) {
362		o.setViewMode(ViewModeHistory)
363	}
364	o.retainedCallbacks = append(o.retainedCallbacks, historyClickCb)
365	o.historyBtn.ConnectClicked(&historyClickCb)
366
367	favoritesClickCb := func(_ gtk.Button) {
368		o.setViewMode(ViewModeFavorites)
369	}
370	o.retainedCallbacks = append(o.retainedCallbacks, favoritesClickCb)
371	o.favoritesBtn.ConnectClicked(&favoritesClickCb)
372
373	o.zoomLabel = gtk.NewLabel(nil)
374	if o.zoomLabel != nil {
375		o.zoomLabel.AddCssClass("omnibox-zoom-indicator")
376		o.zoomLabel.SetVisible(false)
377		o.zoomLabel.SetHalign(gtk.AlignEndValue)
378		o.zoomLabel.SetHexpand(true)
379	}
380	return nil
381}
382
383func (o *Omnibox) initEntry() error {
384	o.entry = gtk.NewSearchEntry()
385	if o.entry == nil {
386		return errNilWidget("entry")
387	}
388	o.entry.AddCssClass("omnibox-entry")
389	o.entry.SetHexpand(true)
390
391	placeholder := "Search history or enter URL..."
392	o.entry.SetPlaceholderText(&placeholder)
393	return nil
394}
395
396func (o *Omnibox) initList() error {
397	o.scrolledWin = gtk.NewScrolledWindow()
398	if o.scrolledWin == nil {
399		return errNilWidget("scrolledWin")
400	}
401	o.scrolledWin.AddCssClass("omnibox-scrolled")
402	o.scrolledWin.SetVexpand(true)
403	o.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
404	o.scrolledWin.SetPropagateNaturalHeight(true)
405
406	o.listBox = gtk.NewListBox()
407	if o.listBox == nil {
408		return errNilWidget("listBox")
409	}
410	o.listBox.AddCssClass("omnibox-listbox")
411	o.listBox.SetSelectionMode(gtk.SelectionSingleValue)
412
413	rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) {
414		if rowPtr == 0 {
415			o.mu.Lock()
416			o.selectedIndex = -1
417			o.mu.Unlock()
418			return
419		}
420		row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
421		if row != nil {
422			o.mu.Lock()
423			o.selectedIndex = row.GetIndex()
424			o.mu.Unlock()
425		}
426	}
427	o.retainedCallbacks = append(o.retainedCallbacks, rowSelectedCb)
428	o.listBox.ConnectRowSelected(&rowSelectedCb)
429	return nil
430}
431
432func (o *Omnibox) assembleWidgets() {
433	o.headerBox.Append(&o.historyBtn.Widget)
434	o.headerBox.Append(&o.favoritesBtn.Widget)
435	if o.zoomLabel != nil {
436		o.headerBox.Append(&o.zoomLabel.Widget)
437	}
438
439	o.scrolledWin.SetChild(&o.listBox.Widget)
440	o.mainBox.Append(&o.headerBox.Widget)
441	o.mainBox.Append(&o.entry.Widget)
442	o.mainBox.Append(&o.scrolledWin.Widget)
443	o.outerBox.Append(&o.mainBox.Widget)
444}
445
446// setupEntryChanged wires entry text changes to debounced search.
447func (o *Omnibox) setupEntryChanged() {
448	// SearchEntry has built-in debouncing via search-changed signal
449	changedCb := func(_ gtk.SearchEntry) {
450		o.onEntryChanged()
451	}
452	o.retainedCallbacks = append(o.retainedCallbacks, changedCb)
453	o.entry.ConnectSearchChanged(&changedCb)
454}
455
456// handleKeyPress processes keyboard events.
457// Returns true if the event was handled.
458func (o *Omnibox) handleKeyPress(keyval uint, state gdk.ModifierType) bool {
459	ctrl := state&gdk.ControlMaskValue != 0
460
461	switch keyval {
462	case uint(gdk.KEY_Escape):
463		text := o.entry.GetText()
464		if text != "" {
465			o.entry.SetText("")
466			return true
467		}
468		o.Hide(o.ctx)
469		return true
470
471	case uint(gdk.KEY_l), uint(gdk.KEY_L):
472		// Ctrl+L toggles omnibox (hides when visible)
473		if ctrl {
474			o.Hide(o.ctx)
475			return true
476		}
477
478	case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
479		o.navigateToSelected()
480		return true
481
482	case uint(gdk.KEY_Up):
483		o.selectPrevious()
484		return true
485
486	case uint(gdk.KEY_Down):
487		o.selectNext()
488		return true
489
490	case uint(gdk.KEY_Tab):
491		o.toggleViewMode()
492		return true
493
494	case uint(gdk.KEY_space):
495		// Space toggles favorite only if user has navigated with arrow keys
496		// Otherwise, let space pass through to entry for typing
497		o.mu.RLock()
498		navigated := o.hasNavigated
499		o.mu.RUnlock()
500		if navigated {
501			o.toggleFavorite()
502			return true
503		}
504		return false // Let entry handle space for typing
505
506	case uint(gdk.KEY_y):
507		// 'y' yanks (copies) the selected URL to clipboard when navigating
508		o.mu.RLock()
509		navigated := o.hasNavigated
510		o.mu.RUnlock()
511		if navigated {
512			o.yankSelectedURL()
513			return true
514		}
515		return false // Let entry handle 'y' for typing
516
517	default:
518		return o.handleCtrlNumberShortcut(keyval, ctrl)
519	}
520
521	return false // Let entry handle the key
522}
523
524// handleCtrlNumberShortcut handles Ctrl+1-9 and Ctrl+0 for quick navigation.
525func (o *Omnibox) handleCtrlNumberShortcut(keyval uint, ctrl bool) bool {
526	if !ctrl {
527		return false
528	}
529
530	const tenthItemIndex = 9
531	switch keyval {
532	case uint(gdk.KEY_1), uint(gdk.KEY_2), uint(gdk.KEY_3), uint(gdk.KEY_4), uint(gdk.KEY_5),
533		uint(gdk.KEY_6), uint(gdk.KEY_7), uint(gdk.KEY_8), uint(gdk.KEY_9):
534		index := int(keyval - uint(gdk.KEY_1))
535		o.selectAndNavigate(index)
536		return true
537	case uint(gdk.KEY_0):
538		o.selectAndNavigate(tenthItemIndex)
539		return true
540	}
541	return false
542}
543
544// onEntryChanged handles text input changes with debouncing.
545func (o *Omnibox) onEntryChanged() {
546	// Reset navigation state when user types - space should type, not toggle favorite
547	o.mu.Lock()
548	o.hasNavigated = false
549	o.mu.Unlock()
550
551	o.debounceMu.Lock()
552	if o.debounceTimer != nil {
553		o.debounceTimer.Stop()
554	}
555	o.debounceTimer = time.AfterFunc(debounceDelayMs*time.Millisecond, func() {
556		o.performSearch()
557	})
558	o.debounceMu.Unlock()
559}
560
561// performSearch executes the search based on current view mode and query.
562func (o *Omnibox) performSearch() {
563	o.mu.RLock()
564	visible := o.visible
565	mode := o.viewMode
566	o.mu.RUnlock()
567
568	// Skip search if omnibox is hidden
569	if !visible {
570		return
571	}
572
573	query := o.entry.GetText()
574
575	// Skip duplicate queries
576	o.debounceMu.Lock()
577	if query == o.lastQuery && query != "" {
578		o.debounceMu.Unlock()
579		return
580	}
581	o.lastQuery = query
582	o.debounceMu.Unlock()
583
584	if mode == ViewModeFavorites {
585		o.loadFavorites(query)
586		return
587	}
588
589	// History search
590	if query == "" {
591		o.loadInitialHistory()
592		return
593	}
594
595	// Perform fuzzy search
596	go func() {
597		ctx := o.ctx
598		log := logging.FromContext(ctx)
599		if o.historyUC == nil {
600			return
601		}
602
603		input := usecase.SearchInput{
604			Query: query,
605			Limit: omniboxMaxResults,
606		}
607		output, err := o.historyUC.Search(ctx, input)
608		if err != nil {
609			log.Error().Err(err).Msg("history search failed")
610			return
611		}
612
613		suggestions := make([]Suggestion, 0, len(output.Matches))
614		for _, r := range output.Matches {
615			suggestions = append(suggestions, Suggestion{
616				URL:   r.Entry.URL,
617				Title: r.Entry.Title,
618			})
619		}
620
621		// Marshal back to GTK main thread
622		o.idleAddUpdateSuggestions(suggestions)
623	}()
624}
625
626// loadInitialHistory loads history based on InitialBehavior config.
627func (o *Omnibox) loadInitialHistory() {
628	if o.historyUC == nil {
629		return
630	}
631
632	go func() {
633		ctx := o.ctx
634		log := logging.FromContext(ctx)
635		var suggestions []Suggestion
636
637		switch o.initialBehavior {
638		case "none":
639			o.idleAddUpdateSuggestions(nil)
640			return
641
642		case "most_visited", "recent", "":
643			// TODO: Implement GetMostVisited in use case if needed
644			results, err := o.historyUC.GetRecent(ctx, omniboxMaxResults, 0)
645			if err != nil {
646				log.Error().Err(err).Msg("failed to load recent history")
647				return
648			}
649			suggestions = make([]Suggestion, 0, len(results))
650			for _, r := range results {
651				suggestions = append(suggestions, Suggestion{
652					URL:   r.URL,
653					Title: r.Title,
654				})
655			}
656		}
657
658		o.idleAddUpdateSuggestions(suggestions)
659	}()
660}
661
662// loadFavorites loads favorites, optionally filtered by query.
663func (o *Omnibox) loadFavorites(query string) {
664	if o.favoritesUC == nil {
665		return
666	}
667
668	go func() {
669		ctx := o.ctx
670		log := logging.FromContext(ctx)
671		results, err := o.favoritesUC.GetAll(ctx)
672		if err != nil {
673			log.Error().Err(err).Msg("failed to load favorites")
674			return
675		}
676
677		favorites := make([]Favorite, 0, len(results))
678		queryLower := strings.ToLower(query)
679		for i, r := range results {
680			// Filter by query if provided
681			if query != "" {
682				titleMatch := strings.Contains(strings.ToLower(r.Title), queryLower)
683				urlMatch := strings.Contains(strings.ToLower(r.URL), queryLower)
684				if !titleMatch && !urlMatch {
685					continue
686				}
687			}
688			favorites = append(favorites, Favorite{
689				ID:       int64(r.ID),
690				URL:      r.URL,
691				Title:    r.Title,
692				Position: i,
693			})
694		}
695
696		o.idleAddUpdateFavorites(favorites)
697	}()
698}
699
700// updateSuggestions updates the list with history suggestions.
701func (o *Omnibox) updateSuggestions(suggestions []Suggestion) {
702	o.mu.Lock()
703	o.suggestions = suggestions
704	o.selectedIndex = -1
705	o.mu.Unlock()
706
707	o.rebuildList()
708
709	// Hide scrolled window when there are no suggestions
710	rowCount := len(suggestions)
711	if o.scrolledWin != nil {
712		o.scrolledWin.SetVisible(rowCount > 0)
713	}
714	o.resizeAndCenter(rowCount)
715
716	// Select first item if available
717	if rowCount > 0 {
718		o.selectIndex(0)
719	}
720}
721
722// updateFavorites updates the list with favorites.
723func (o *Omnibox) updateFavorites(favorites []Favorite) {
724	o.mu.Lock()
725	o.favorites = favorites
726	o.selectedIndex = -1
727	o.mu.Unlock()
728
729	o.rebuildList()
730
731	// Hide scrolled window when there are no favorites
732	rowCount := len(favorites)
733	if o.scrolledWin != nil {
734		o.scrolledWin.SetVisible(rowCount > 0)
735	}
736	o.resizeAndCenter(rowCount)
737
738	// Select first item if available
739	if rowCount > 0 {
740		o.selectIndex(0)
741	}
742}
743
744// rebuildList rebuilds the ListBox contents.
745func (o *Omnibox) rebuildList() {
746	// Clear existing rows
747	o.listBox.RemoveAll()
748
749	o.mu.RLock()
750	mode := o.viewMode
751	suggestions := o.suggestions
752	favorites := o.favorites
753	o.mu.RUnlock()
754
755	if mode == ViewModeHistory {
756		for i, s := range suggestions {
757			row := o.createSuggestionRow(s, i)
758			if row != nil {
759				o.listBox.Append(&row.Widget)
760			}
761		}
762	} else {
763		for i, f := range favorites {
764			row := o.createFavoriteRow(f, i)
765			if row != nil {
766				o.listBox.Append(&row.Widget)
767			}
768		}
769	}
770
771	// Schedule measurement if not yet cached and we have rows
772	if !o.measuredHeights.valid && o.listBox.GetRowAtIndex(0) != nil {
773		var cb glib.SourceFunc = func(uintptr) bool {
774			if o.parentOverlay == nil {
775				return false
776			}
777			width := o.parentOverlay.GetAllocatedWidth()
778			if width <= 0 {
779				return false // Overlay not allocated yet, skip
780			}
781			forWidth := int(float64(width) * omniboxWidthPct)
782			if o.measureComponentHeights(forWidth) {
783				// Re-trigger resize with accurate measurements
784				o.mu.RLock()
785				var count int
786				if o.viewMode == ViewModeHistory {
787					count = len(o.suggestions)
788				} else {
789					count = len(o.favorites)
790				}
791				o.mu.RUnlock()
792				o.measureAndResize(forWidth, count)
793			}
794			return false
795		}
796		glib.IdleAdd(&cb, 0)
797	}
798}
799
800// createFaviconImage creates a favicon image with async loading from cache.
801func (o *Omnibox) createFaviconImage(rawURL, fallbackIcon string) *gtk.Image {
802	favicon := gtk.NewImage()
803	if favicon == nil {
804		return nil
805	}
806	favicon.SetFromIconName(&fallbackIcon)
807	favicon.SetPixelSize(int(16 * o.uiScale))
808	favicon.AddCssClass("omnibox-favicon")
809
810	// Async load favicon from cache
811	if o.faviconAdapter != nil && rawURL != "" {
812		o.faviconAdapter.GetOrFetch(o.ctx, rawURL, func(texture *gdk.Texture) {
813			if texture != nil {
814				var cb glib.SourceFunc = func(data uintptr) bool {
815					favicon.SetFromPaintable(texture)
816					return false
817				}
818				glib.IdleAdd(&cb, 0)
819			}
820		})
821	}
822
823	return favicon
824}
825
826// createRowWithFavicon creates a ListBoxRow with favicon, title, URL, and shortcut badge.
827func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index int) *gtk.ListBoxRow {
828	row := gtk.NewListBoxRow()
829	if row == nil {
830		return nil
831	}
832	row.AddCssClass("omnibox-row")
833
834	const rowSpacing = 8
835	hbox := gtk.NewBox(gtk.OrientationHorizontalValue, rowSpacing)
836	if hbox == nil {
837		return nil
838	}
839	hbox.SetHexpand(true)
840
841	// Favicon image (vertically centered)
842	if favicon := o.createFaviconImage(rawURL, fallbackIcon); favicon != nil {
843		favicon.SetValign(gtk.AlignCenterValue)
844		hbox.Append(&favicon.Widget)
845	}
846
847	// Vertical box for title + URL
848	textBox := gtk.NewBox(gtk.OrientationVerticalValue, 2)
849	if textBox == nil {
850		return nil
851	}
852	textBox.SetHexpand(true)
853	textBox.SetValign(gtk.AlignCenterValue)
854
855	// Title label (or URL if no title)
856	displayTitle := title
857	if displayTitle == "" {
858		displayTitle = rawURL
859	}
860	titleLabel := gtk.NewLabel(nil)
861	if titleLabel != nil {
862		titleLabel.SetText(displayTitle)
863		titleLabel.AddCssClass("omnibox-suggestion-title")
864		titleLabel.SetHalign(gtk.AlignStartValue)
865		titleLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END
866		textBox.Append(&titleLabel.Widget)
867	}
868
869	// URL label (only if title exists and differs from URL)
870	if title != "" && title != rawURL {
871		urlLabel := gtk.NewLabel(nil)
872		if urlLabel != nil {
873			urlLabel.SetText(rawURL)
874			urlLabel.AddCssClass("omnibox-suggestion-url")
875			urlLabel.SetHalign(gtk.AlignStartValue)
876			urlLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END
877			textBox.Append(&urlLabel.Widget)
878		}
879	}
880
881	hbox.Append(&textBox.Widget)
882
883	// Shortcut badge (Ctrl+1-9, Ctrl+0 for 10th)
884	const maxShortcutIndex = 9
885	if index <= maxShortcutIndex {
886		shortcutLabel := gtk.NewLabel(nil)
887		if shortcutLabel != nil {
888			if index < maxShortcutIndex {
889				shortcutLabel.SetText(formatShortcut(index + 1))
890			} else {
891				shortcutLabel.SetText("Ctrl+0")
892			}
893			shortcutLabel.AddCssClass("omnibox-shortcut-badge")
894			shortcutLabel.SetValign(gtk.AlignCenterValue)
895			hbox.Append(&shortcutLabel.Widget)
896		}
897	}
898
899	row.SetChild(&hbox.Widget)
900	return row
901}
902
903// createSuggestionRow creates a ListBoxRow for a suggestion.
904func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow {
905	return o.createRowWithFavicon(s.URL, s.Title, "web-browser-symbolic", index)
906}
907
908// createFavoriteRow creates a ListBoxRow for a favorite.
909func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow {
910	return o.createRowWithFavicon(f.URL, f.Title, "starred-symbolic", index)
911}
912
913// selectIndex selects a row by index.
914func (o *Omnibox) selectIndex(index int) {
915	// Note: Don't hold mutex when calling SelectRow - it triggers rowSelectedCb
916	// which also acquires the mutex, causing deadlock
917	row := o.listBox.GetRowAtIndex(index)
918	if row != nil {
919		o.listBox.SelectRow(row)
920		// selectedIndex is updated by rowSelectedCb
921	}
922}
923
924// selectNext moves selection down.
925func (o *Omnibox) selectNext() {
926	o.mu.Lock()
927	current := o.selectedIndex
928	mode := o.viewMode
929	var maxIndex int
930	if mode == ViewModeHistory {
931		maxIndex = len(o.suggestions) - 1
932	} else {
933		maxIndex = len(o.favorites) - 1
934	}
935	o.hasNavigated = true // User is navigating with arrow keys
936	o.mu.Unlock()
937
938	if maxIndex < 0 {
939		return
940	}
941
942	newIndex := current + 1
943	if newIndex > maxIndex {
944		newIndex = 0 // Wrap around
945	}
946	o.selectIndex(newIndex)
947}
948
949// selectPrevious moves selection up.
950func (o *Omnibox) selectPrevious() {
951	o.mu.Lock()
952	current := o.selectedIndex
953	mode := o.viewMode
954	var maxIndex int
955	if mode == ViewModeHistory {
956		maxIndex = len(o.suggestions) - 1
957	} else {
958		maxIndex = len(o.favorites) - 1
959	}
960	o.hasNavigated = true // User is navigating with arrow keys
961	o.mu.Unlock()
962
963	if maxIndex < 0 {
964		return
965	}
966
967	newIndex := current - 1
968	if newIndex < 0 {
969		newIndex = maxIndex // Wrap around
970	}
971	o.selectIndex(newIndex)
972}
973
974// selectAndNavigate selects an index and navigates to it.
975func (o *Omnibox) selectAndNavigate(index int) {
976	o.selectIndex(index)
977	o.navigateToSelected()
978}
979
980// navigateToSelected navigates to the currently selected item or typed URL.
981// If the user typed a URL-like string, prioritize navigating to that directly.
982func (o *Omnibox) navigateToSelected() {
983	o.mu.RLock()
984	mode := o.viewMode
985	idx := o.selectedIndex
986	suggestions := o.suggestions
987	favorites := o.favorites
988	o.mu.RUnlock()
989
990	entryText := o.entry.GetText()
991	var targetURL string
992
993	// Check if user typed a URL-like string (contains . and no spaces)
994	// If so, navigate to that URL directly instead of the selection
995	if o.looksLikeURL(entryText) {
996		targetURL = o.buildURL(entryText)
997	} else if idx < 0 {
998		// No selection - use entry text as URL/search
999		targetURL = o.buildURL(entryText)
1000	} else if mode == ViewModeHistory {
1001		if idx < len(suggestions) {
1002			targetURL = suggestions[idx].URL
1003		}
1004	} else {
1005		if idx < len(favorites) {
1006			targetURL = favorites[idx].URL
1007		}
1008	}
1009
1010	if targetURL == "" {
1011		return
1012	}
1013
1014	o.Hide(o.ctx)
1015
1016	if o.onNavigate != nil {
1017		o.onNavigate(targetURL)
1018	}
1019}
1020
1021// looksLikeURL checks if the input appears to be a URL (not a search query).
1022// Returns true for strings like "github.com", "google.com/search", etc.
1023func (o *Omnibox) looksLikeURL(input string) bool {
1024	return url.LooksLikeURL(input)
1025}
1026
1027// toggleFavorite adds or removes the selected item from favorites.
1028// In History mode: adds the selected item to favorites
1029// In Favorites mode: removes the selected item from favorites
1030func (o *Omnibox) toggleFavorite() {
1031	log := logging.FromContext(o.ctx)
1032
1033	o.mu.RLock()
1034	mode := o.viewMode
1035	idx := o.selectedIndex
1036	suggestions := o.suggestions
1037	favorites := o.favorites
1038	o.mu.RUnlock()
1039
1040	if o.favoritesUC == nil {
1041		log.Warn().Msg("toggle favorite: favoritesUC is nil")
1042		return
1043	}
1044
1045	if mode == ViewModeHistory {
1046		// Add to favorites
1047		if idx < 0 || idx >= len(suggestions) {
1048			log.Debug().Int("index", idx).Msg("add favorite: invalid selection")
1049			return
1050		}
1051
1052		s := suggestions[idx]
1053		if s.URL == "" {
1054			log.Debug().Msg("add favorite: empty URL")
1055			return
1056		}
1057
1058		go func() {
1059			ctx := o.ctx
1060			goLog := logging.FromContext(ctx)
1061
1062			goLog.Debug().Str("url", s.URL).Str("title", s.Title).Msg("adding to favorites")
1063
1064			input := usecase.AddFavoriteInput{
1065				URL:   s.URL,
1066				Title: s.Title,
1067			}
1068
1069			fav, err := o.favoritesUC.Add(ctx, input)
1070			if err != nil {
1071				goLog.Error().Err(err).Str("url", s.URL).Msg("failed to add favorite")
1072				return
1073			}
1074
1075			goLog.Info().Str("url", s.URL).Int64("id", int64(fav.ID)).Msg("favorite added from omnibox")
1076		}()
1077	} else {
1078		// Remove from favorites
1079		if idx < 0 || idx >= len(favorites) {
1080			log.Debug().Int("index", idx).Msg("remove favorite: invalid selection")
1081			return
1082		}
1083
1084		f := favorites[idx]
1085		if f.ID == 0 {
1086			log.Debug().Msg("remove favorite: invalid ID")
1087			return
1088		}
1089
1090		go func() {
1091			ctx := o.ctx
1092			log := logging.FromContext(ctx)
1093
1094			log.Debug().Int64("id", f.ID).Str("url", f.URL).Msg("removing from favorites")
1095
1096			err := o.favoritesUC.Remove(ctx, entity.FavoriteID(f.ID))
1097			if err != nil {
1098				log.Error().Err(err).Int64("id", f.ID).Msg("failed to remove favorite")
1099				return
1100			}
1101
1102			log.Info().Int64("id", f.ID).Str("url", f.URL).Msg("favorite removed from omnibox")
1103
1104			// Refresh favorites list
1105			o.loadFavorites(o.entry.GetText())
1106		}()
1107	}
1108}
1109
1110// yankSelectedURL copies the URL of the selected item to clipboard.
1111func (o *Omnibox) yankSelectedURL() {
1112	log := logging.FromContext(o.ctx)
1113
1114	if o.copyURLUC == nil {
1115		log.Warn().Msg("yank URL: copy URL use case is nil")
1116		return
1117	}
1118
1119	o.mu.RLock()
1120	mode := o.viewMode
1121	idx := o.selectedIndex
1122	suggestions := o.suggestions
1123	favorites := o.favorites
1124	o.mu.RUnlock()
1125
1126	var selectedURL string
1127	if mode == ViewModeHistory {
1128		if idx < 0 || idx >= len(suggestions) {
1129			log.Debug().Int("index", idx).Msg("yank URL: invalid selection")
1130			return
1131		}
1132		selectedURL = suggestions[idx].URL
1133	} else {
1134		if idx < 0 || idx >= len(favorites) {
1135			log.Debug().Int("index", idx).Msg("yank URL: invalid selection")
1136			return
1137		}
1138		selectedURL = favorites[idx].URL
1139	}
1140
1141	if selectedURL == "" {
1142		log.Debug().Msg("yank URL: empty URL")
1143		return
1144	}
1145
1146	go func() {
1147		ctx := o.ctx
1148		if err := o.copyURLUC.Copy(ctx, selectedURL); err != nil {
1149			return // Use case already logs the error
1150		}
1151
1152		// Show toast notification on success (must run on GTK main thread)
1153		if o.onToast != nil {
1154			cb := glib.SourceFunc(func(_ uintptr) bool {
1155				o.onToast("URL copied")
1156				return false // Don't repeat
1157			})
1158			glib.IdleAdd(&cb, 0)
1159		}
1160	}()
1161}
1162
1163// buildURL constructs a URL from input, handling search shortcuts.
1164func (o *Omnibox) buildURL(input string) string {
1165	return url.BuildSearchURL(input, o.shortcutURLs(), o.defaultSearch)
1166}
1167
1168// shortcutURLs returns a map of shortcut keys to URL templates.
1169func (o *Omnibox) shortcutURLs() map[string]string {
1170	result := make(map[string]string, len(o.shortcuts))
1171	for key, shortcut := range o.shortcuts {
1172		result[key] = shortcut.URL
1173	}
1174	return result
1175}
1176
1177// toggleViewMode switches between history and favorites.
1178func (o *Omnibox) toggleViewMode() {
1179	o.mu.RLock()
1180	current := o.viewMode
1181	o.mu.RUnlock()
1182
1183	if current == ViewModeHistory {
1184		o.setViewMode(ViewModeFavorites)
1185	} else {
1186		o.setViewMode(ViewModeHistory)
1187	}
1188}
1189
1190// setViewMode changes the view mode and updates UI.
1191func (o *Omnibox) setViewMode(mode ViewMode) {
1192	o.mu.Lock()
1193	o.viewMode = mode
1194	o.mu.Unlock()
1195
1196	// Update header button styling
1197	if mode == ViewModeHistory {
1198		o.historyBtn.AddCssClass("omnibox-header-active")
1199		o.favoritesBtn.RemoveCssClass("omnibox-header-active")
1200	} else {
1201		o.historyBtn.RemoveCssClass("omnibox-header-active")
1202		o.favoritesBtn.AddCssClass("omnibox-header-active")
1203	}
1204
1205	// Reload data
1206	o.performSearch()
1207}
1208
1209// Show opens the omnibox with optional initial query.
1210func (o *Omnibox) Show(ctx context.Context, query string) {
1211	log := logging.FromContext(ctx)
1212	log.Debug().Str("query", query).Msg("showing omnibox")
1213
1214	o.mu.Lock()
1215	if o.visible {
1216		o.mu.Unlock()
1217		return
1218	}
1219	o.visible = true
1220	o.mu.Unlock()
1221
1222	// Set initial query
1223	o.entry.SetText(query)
1224
1225	// Determine if we expect content initially
1226	// No content expected if: no query AND initialBehavior is "none"
1227	expectContent := query != "" || o.initialBehavior != "none"
1228
1229	// Hide scrolled window if no content expected
1230	if o.scrolledWin != nil {
1231		o.scrolledWin.SetVisible(expectContent)
1232		// Reset content height constraints - will be updated when results arrive
1233		o.scrolledWin.SetMinContentHeight(-1)
1234		o.scrolledWin.SetMaxContentHeight(0)
1235	}
1236
1237	// Get parent dimensions with fallback if not yet allocated
1238	parentWidth := o.parentOverlay.GetAllocatedWidth()
1239	parentHeight := o.parentOverlay.GetAllocatedHeight()
1240	if parentWidth <= 0 {
1241		parentWidth = omniboxMaxWidth
1242	}
1243	if parentHeight <= 0 {
1244		parentHeight = omniboxFallbackHeight
1245	}
1246
1247	// Set width and vertical positioning (20% from top)
1248	width := int(float64(parentWidth) * omniboxWidthPct)
1249	if width > omniboxMaxWidth {
1250		width = omniboxMaxWidth
1251	}
1252	marginTop := int(float64(parentHeight) * omniboxTopMarginPct)
1253
1254	o.mainBox.SetSizeRequest(width, -1)
1255	o.outerBox.SetMarginTop(marginTop)
1256
1257	// Show the omnibox
1258	o.outerBox.SetVisible(true)
1259
1260	// Trigger initial resize (will be updated when results arrive)
1261	o.resizeAndCenter(0)
1262
1263	// Focus the entry
1264	o.entry.GrabFocus()
1265
1266	// Load initial data (may update size later if results found)
1267	o.performSearch()
1268}
1269
1270// Hide closes the omnibox.
1271func (o *Omnibox) Hide(ctx context.Context) {
1272	log := logging.FromContext(ctx)
1273	log.Debug().Msg("hiding omnibox")
1274
1275	o.mu.Lock()
1276	if !o.visible {
1277		o.mu.Unlock()
1278		return
1279	}
1280	o.visible = false
1281	o.mu.Unlock()
1282
1283	// Cancel any pending search
1284	o.debounceMu.Lock()
1285	if o.debounceTimer != nil {
1286		o.debounceTimer.Stop()
1287		o.debounceTimer = nil
1288	}
1289	o.debounceMu.Unlock()
1290
1291	// Hide omnibox
1292	o.outerBox.SetVisible(false)
1293
1294	// Clear state
1295	o.entry.SetText("")
1296	o.listBox.RemoveAll()
1297
1298	if o.onClose != nil {
1299		o.onClose()
1300	}
1301}
1302
1303// Toggle shows if hidden, hides if visible.
1304func (o *Omnibox) Toggle(ctx context.Context) {
1305	o.mu.RLock()
1306	visible := o.visible
1307	o.mu.RUnlock()
1308
1309	if visible {
1310		o.Hide(ctx)
1311	} else {
1312		o.Show(ctx, "")
1313	}
1314}
1315
1316// IsVisible returns whether the omnibox is currently shown.
1317func (o *Omnibox) IsVisible() bool {
1318	o.mu.RLock()
1319	defer o.mu.RUnlock()
1320	return o.visible
1321}
1322
1323// SetOnNavigate sets the callback for URL navigation.
1324func (o *Omnibox) SetOnNavigate(fn func(url string)) {
1325	o.onNavigate = fn
1326}
1327
1328// SetOnClose sets the callback for omnibox close events.
1329func (o *Omnibox) SetOnClose(fn func()) {
1330	o.onClose = fn
1331}
1332
1333// UpdateZoomIndicator updates the zoom percentage display.
1334// Shows the indicator when zoom != 100%, hides it when at 100%.
1335func (o *Omnibox) UpdateZoomIndicator(factor float64) {
1336	var cb glib.SourceFunc = func(data uintptr) bool {
1337		if o.zoomLabel == nil {
1338			return false
1339		}
1340		if factor == 1.0 {
1341			o.zoomLabel.SetVisible(false)
1342		} else {
1343			percentage := int(factor * 100)
1344			o.zoomLabel.SetText(fmt.Sprintf("%d%%", percentage))
1345			o.zoomLabel.SetVisible(true)
1346		}
1347		return false
1348	}
1349	glib.IdleAdd(&cb, 0)
1350}
1351
1352// idleAddUpdateSuggestions schedules updateSuggestions on the GTK main thread.
1353func (o *Omnibox) idleAddUpdateSuggestions(suggestions []Suggestion) {
1354	var cb glib.SourceFunc = func(data uintptr) bool {
1355		o.updateSuggestions(suggestions)
1356		return false // One-shot callback
1357	}
1358	glib.IdleAdd(&cb, 0)
1359}
1360
1361// idleAddUpdateFavorites schedules updateFavorites on the GTK main thread.
1362func (o *Omnibox) idleAddUpdateFavorites(favorites []Favorite) {
1363	var cb glib.SourceFunc = func(data uintptr) bool {
1364		o.updateFavorites(favorites)
1365		return false // One-shot callback, return false to remove source
1366	}
1367	glib.IdleAdd(&cb, 0)
1368}
1369
1370// Widget returns the omnibox widget for embedding in an overlay.
1371func (o *Omnibox) Widget() *gtk.Widget {
1372	if o.outerBox == nil {
1373		return nil
1374	}
1375	return &o.outerBox.Widget
1376}
1377
1378// Destroy cleans up omnibox resources.
1379func (o *Omnibox) Destroy() {
1380	o.debounceMu.Lock()
1381	if o.debounceTimer != nil {
1382		o.debounceTimer.Stop()
1383	}
1384	o.debounceMu.Unlock()
1385
1386	o.mu.Lock()
1387	o.visible = false
1388	o.mu.Unlock()
1389
1390	o.parentOverlay = nil
1391	o.retainedCallbacks = nil
1392
1393	if o.outerBox != nil {
1394		o.outerBox.Unparent()
1395		o.outerBox = nil
1396	}
1397}
1398
1399// formatShortcut formats a shortcut number.
1400func formatShortcut(n int) string {
1401	return "Ctrl+" + string(rune('0'+n))
1402}
1403
1404// measureWidgetHeight returns the natural height of a widget for the given width.
1405// Returns 0 if widget is nil or measurement fails.
1406func measureWidgetHeight(widget *gtk.Widget, forWidth int) int {
1407	if widget == nil {
1408		return 0
1409	}
1410	var minHeight, naturalHeight, minBaseline, naturalBaseline int
1411	widget.Measure(
1412		gtk.OrientationVerticalValue,
1413		forWidth,
1414		&minHeight,
1415		&naturalHeight,
1416		&minBaseline,
1417		&naturalBaseline,
1418	)
1419	return naturalHeight
1420}
1421
1422// measureComponentHeights measures and caches all component heights.
1423// Must be called on GTK main thread after widgets have been laid out.
1424func (o *Omnibox) measureComponentHeights(forWidth int) bool {
1425	if o.headerBox == nil || o.entry == nil || o.listBox == nil {
1426		return false
1427	}
1428
1429	headerH := measureWidgetHeight(&o.headerBox.Widget, forWidth)
1430	entryH := measureWidgetHeight(&o.entry.Widget, forWidth)
1431
1432	// Measure first row if it exists
1433	var rowH int
1434	if row := o.listBox.GetRowAtIndex(0); row != nil {
1435		rowH = measureWidgetHeight(&row.Widget, forWidth)
1436	}
1437
1438	// Validate measurements (header and entry must be positive)
1439	if headerH <= 0 || entryH <= 0 {
1440		return false
1441	}
1442
1443	o.measuredHeights.header = headerH
1444	o.measuredHeights.entry = entryH
1445	if rowH > 0 {
1446		o.measuredHeights.singleRow = rowH
1447		o.measuredHeights.valid = true
1448	}
1449	return true
1450}
1451
1452// errNilWidget creates an error for nil widget creation.
1453func errNilWidget(name string) error {
1454	return &widgetError{name: name}
1455}
1456
1457type widgetError struct {
1458	name string
1459}
1460
1461func (e *widgetError) Error() string {
1462	return "failed to create widget: " + e.name
1463}

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