v0.21.0 73bf644

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

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