detached 2af3604

Merge pull request #51 from bnema/feature/omnibox-favorite-indicator

feat(omnibox): add favorite indicator for history items
commit
2af36045b031070a8a957c812de89d03553037a1
author
bnema · · 2025-12-28 08:44 UTC
committer
GitHub · · 2025-12-28 08:44 UTC
merge of
e869777 + 89aeee6 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
7 files · +225 -25
M CHANGELOG.md +1 -0
@@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file.
55 ## [Unreleased]
66
77 ### Added
8+- **Omnibox favorite indicator**: History items that are also bookmarked now display a yellowish left border and subtle background tint in the omnibox. Uses parallel async loading to avoid blocking browser startup.
89 - **Mode indicator toaster**: Visual notification showing current modal mode (PANE MODE, TAB MODE, SESSION MODE, RESIZE MODE) at bottom-left corner with mode-specific colors. Configurable via `workspace.styling.mode_indicator_toaster_enabled` (default: true).
910 - **Config migration system**: Detects missing config keys when new settings are added in updates and provides tools to add them.
1011 - `dumber config status`: Shows config file path and number of new settings available.
@@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file.
5 ## [Unreleased]5 ## [Unreleased]
6 6
7 ### Added7 ### Added
8+- **Omnibox favorite indicator**: History items that are also bookmarked now display a yellowish left border and subtle background tint in the omnibox. Uses parallel async loading to avoid blocking browser startup.
8 - **Mode indicator toaster**: Visual notification showing current modal mode (PANE MODE, TAB MODE, SESSION MODE, RESIZE MODE) at bottom-left corner with mode-specific colors. Configurable via `workspace.styling.mode_indicator_toaster_enabled` (default: true).9 - **Mode indicator toaster**: Visual notification showing current modal mode (PANE MODE, TAB MODE, SESSION MODE, RESIZE MODE) at bottom-left corner with mode-specific colors. Configurable via `workspace.styling.mode_indicator_toaster_enabled` (default: true).
9 - **Config migration system**: Detects missing config keys when new settings are added in updates and provides tools to add them.10 - **Config migration system**: Detects missing config keys when new settings are added in updates and provides tools to add them.
10 - `dumber config status`: Shows config file path and number of new settings available.11 - `dumber config status`: Shows config file path and number of new settings available.
M internal/application/usecase/manage_favorites.go +19 -0
@@ -196,6 +196,25 @@ func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, url string) (b
196196 return fav != nil, nil
197197 }
198198
199+// GetAllURLs returns a set of all favorited URLs for efficient batch lookup.
200+func (uc *ManageFavoritesUseCase) GetAllURLs(ctx context.Context) (map[string]struct{}, error) {
201+ log := logging.FromContext(ctx)
202+ log.Debug().Msg("getting all favorite URLs")
203+
204+ favs, err := uc.favoriteRepo.GetAll(ctx)
205+ if err != nil {
206+ return nil, fmt.Errorf("failed to get favorites: %w", err)
207+ }
208+
209+ urls := make(map[string]struct{}, len(favs))
210+ for _, fav := range favs {
211+ urls[fav.URL] = struct{}{}
212+ }
213+
214+ log.Debug().Int("count", len(urls)).Msg("retrieved favorite URLs")
215+ return urls, nil
216+}
217+
199218 // GetTree builds a complete hierarchical view of folders and favorites.
200219 func (uc *ManageFavoritesUseCase) GetTree(ctx context.Context) (*entity.FavoriteTree, error) {
201220 log := logging.FromContext(ctx)
@@ -196,6 +196,25 @@ func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, url string) (b
196 return fav != nil, nil196 return fav != nil, nil
197 }197 }
198 198
199+// GetAllURLs returns a set of all favorited URLs for efficient batch lookup.
200+func (uc *ManageFavoritesUseCase) GetAllURLs(ctx context.Context) (map[string]struct{}, error) {
201+ log := logging.FromContext(ctx)
202+ log.Debug().Msg("getting all favorite URLs")
203+
204+ favs, err := uc.favoriteRepo.GetAll(ctx)
205+ if err != nil {
206+ return nil, fmt.Errorf("failed to get favorites: %w", err)
207+ }
208+
209+ urls := make(map[string]struct{}, len(favs))
210+ for _, fav := range favs {
211+ urls[fav.URL] = struct{}{}
212+ }
213+
214+ log.Debug().Int("count", len(urls)).Msg("retrieved favorite URLs")
215+ return urls, nil
216+}
217+
199 // GetTree builds a complete hierarchical view of folders and favorites.218 // GetTree builds a complete hierarchical view of folders and favorites.
200 func (uc *ManageFavoritesUseCase) GetTree(ctx context.Context) (*entity.FavoriteTree, error) {219 func (uc *ManageFavoritesUseCase) GetTree(ctx context.Context) (*entity.FavoriteTree, error) {
201 log := logging.FromContext(ctx)220 log := logging.FromContext(ctx)
A internal/application/usecase/manage_favorites_test.go +77 -0
@@ -0,0 +1,77 @@
1+package usecase_test
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/usecase"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestManageFavoritesUseCase_GetAllURLs_ReturnsURLSet(t *testing.T) {
16+ ctx := testContext()
17+
18+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
19+ folderRepo := repomocks.NewMockFolderRepository(t)
20+ tagRepo := repomocks.NewMockTagRepository(t)
21+
22+ favorites := []*entity.Favorite{
23+ {ID: 1, URL: "https://example.com"},
24+ {ID: 2, URL: "https://github.com"},
25+ {ID: 3, URL: "https://google.com"},
26+ }
27+
28+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil)
29+
30+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
31+
32+ urls, err := uc.GetAllURLs(ctx)
33+ require.NoError(t, err)
34+ require.NotNil(t, urls)
35+
36+ assert.Len(t, urls, 3)
37+ _, hasExample := urls["https://example.com"]
38+ _, hasGithub := urls["https://github.com"]
39+ _, hasGoogle := urls["https://google.com"]
40+ assert.True(t, hasExample)
41+ assert.True(t, hasGithub)
42+ assert.True(t, hasGoogle)
43+}
44+
45+func TestManageFavoritesUseCase_GetAllURLs_ReturnsEmptySetWhenNoFavorites(t *testing.T) {
46+ ctx := testContext()
47+
48+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
49+ folderRepo := repomocks.NewMockFolderRepository(t)
50+ tagRepo := repomocks.NewMockTagRepository(t)
51+
52+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return([]*entity.Favorite{}, nil)
53+
54+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
55+
56+ urls, err := uc.GetAllURLs(ctx)
57+ require.NoError(t, err)
58+ require.NotNil(t, urls)
59+ assert.Empty(t, urls)
60+}
61+
62+func TestManageFavoritesUseCase_GetAllURLs_ReturnsErrorOnRepoFailure(t *testing.T) {
63+ ctx := testContext()
64+
65+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
66+ folderRepo := repomocks.NewMockFolderRepository(t)
67+ tagRepo := repomocks.NewMockTagRepository(t)
68+
69+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return(nil, errors.New("db connection failed"))
70+
71+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
72+
73+ urls, err := uc.GetAllURLs(ctx)
74+ require.Error(t, err)
75+ assert.Nil(t, urls)
76+ assert.Contains(t, err.Error(), "failed to get favorites")
77+}
@@ -0,0 +1,77 @@
1+package usecase_test
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/usecase"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestManageFavoritesUseCase_GetAllURLs_ReturnsURLSet(t *testing.T) {
16+ ctx := testContext()
17+
18+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
19+ folderRepo := repomocks.NewMockFolderRepository(t)
20+ tagRepo := repomocks.NewMockTagRepository(t)
21+
22+ favorites := []*entity.Favorite{
23+ {ID: 1, URL: "https://example.com"},
24+ {ID: 2, URL: "https://github.com"},
25+ {ID: 3, URL: "https://google.com"},
26+ }
27+
28+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil)
29+
30+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
31+
32+ urls, err := uc.GetAllURLs(ctx)
33+ require.NoError(t, err)
34+ require.NotNil(t, urls)
35+
36+ assert.Len(t, urls, 3)
37+ _, hasExample := urls["https://example.com"]
38+ _, hasGithub := urls["https://github.com"]
39+ _, hasGoogle := urls["https://google.com"]
40+ assert.True(t, hasExample)
41+ assert.True(t, hasGithub)
42+ assert.True(t, hasGoogle)
43+}
44+
45+func TestManageFavoritesUseCase_GetAllURLs_ReturnsEmptySetWhenNoFavorites(t *testing.T) {
46+ ctx := testContext()
47+
48+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
49+ folderRepo := repomocks.NewMockFolderRepository(t)
50+ tagRepo := repomocks.NewMockTagRepository(t)
51+
52+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return([]*entity.Favorite{}, nil)
53+
54+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
55+
56+ urls, err := uc.GetAllURLs(ctx)
57+ require.NoError(t, err)
58+ require.NotNil(t, urls)
59+ assert.Empty(t, urls)
60+}
61+
62+func TestManageFavoritesUseCase_GetAllURLs_ReturnsErrorOnRepoFailure(t *testing.T) {
63+ ctx := testContext()
64+
65+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
66+ folderRepo := repomocks.NewMockFolderRepository(t)
67+ tagRepo := repomocks.NewMockTagRepository(t)
68+
69+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return(nil, errors.New("db connection failed"))
70+
71+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
72+
73+ urls, err := uc.GetAllURLs(ctx)
74+ require.Error(t, err)
75+ assert.Nil(t, urls)
76+ assert.Contains(t, err.Error(), "failed to get favorites")
77+}
M internal/bootstrap/session.go +4 -0
@@ -89,6 +89,10 @@ func StartBrowserSession(
8989 persistErr error
9090 )
9191
92+ // persistFn ensures the session is saved exactly once. Using sync.Once is intentional:
93+ // if the first persist fails, we cache the error and return it on subsequent calls
94+ // rather than retrying. This prevents duplicate session records and ensures consistent
95+ // error handling during shutdown sequences where persistFn may be called multiple times.
9296 persistFn := func(persistCtx context.Context) error {
9397 persistMu.Do(func() {
9498 saveCtx := corelogging.WithContext(context.Background(), logger)
@@ -89,6 +89,10 @@ func StartBrowserSession(
89 persistErr error89 persistErr error
90 )90 )
91 91
92+ // persistFn ensures the session is saved exactly once. Using sync.Once is intentional:
93+ // if the first persist fails, we cache the error and return it on subsequent calls
94+ // rather than retrying. This prevents duplicate session records and ensures consistent
95+ // error handling during shutdown sequences where persistFn may be called multiple times.
92 persistFn := func(persistCtx context.Context) error {96 persistFn := func(persistCtx context.Context) error {
93 persistMu.Do(func() {97 persistMu.Do(func() {
94 saveCtx := corelogging.WithContext(context.Background(), logger)98 saveCtx := corelogging.WithContext(context.Background(), logger)
M internal/infrastructure/persistence/sqlite/lazy_db.go +18 -6
@@ -42,18 +42,30 @@ func (l *LazyDB) DB(ctx context.Context) (*sql.DB, error) {
4242 log := logging.FromContext(ctx)
4343 log.Debug().Str("path", l.dbPath).Msg("lazy database initialization starting")
4444
45- l.db, l.err = NewConnection(ctx, l.dbPath)
46- if l.err != nil {
47- log.Error().Err(l.err).Msg("lazy database initialization failed")
45+ db, err := NewConnection(ctx, l.dbPath)
46+
47+ // Acquire lock before assigning to ensure IsInitialized() sees
48+ // a consistent state when reading l.db under RLock.
49+ l.mu.Lock()
50+ l.db = db
51+ l.err = err
52+ l.mu.Unlock()
53+
54+ if err != nil {
55+ log.Error().Err(err).Msg("lazy database initialization failed")
4856 } else {
4957 log.Debug().Msg("lazy database initialized successfully")
5058 }
5159 })
5260
53- if l.err != nil {
54- return nil, fmt.Errorf("database initialization failed: %w", l.err)
61+ l.mu.RLock()
62+ db, err := l.db, l.err
63+ l.mu.RUnlock()
64+
65+ if err != nil {
66+ return nil, fmt.Errorf("database initialization failed: %w", err)
5567 }
56- return l.db, nil
68+ return db, nil
5769 }
5870
5971 // Close closes the database connection if it was initialized.
@@ -42,18 +42,30 @@ func (l *LazyDB) DB(ctx context.Context) (*sql.DB, error) {
42 log := logging.FromContext(ctx)42 log := logging.FromContext(ctx)
43 log.Debug().Str("path", l.dbPath).Msg("lazy database initialization starting")43 log.Debug().Str("path", l.dbPath).Msg("lazy database initialization starting")
44 44
45- l.db, l.err = NewConnection(ctx, l.dbPath)45+ db, err := NewConnection(ctx, l.dbPath)
46- if l.err != nil {46+
47- log.Error().Err(l.err).Msg("lazy database initialization failed")47+ // Acquire lock before assigning to ensure IsInitialized() sees
48+ // a consistent state when reading l.db under RLock.
49+ l.mu.Lock()
50+ l.db = db
51+ l.err = err
52+ l.mu.Unlock()
53+
54+ if err != nil {
55+ log.Error().Err(err).Msg("lazy database initialization failed")
48 } else {56 } else {
49 log.Debug().Msg("lazy database initialized successfully")57 log.Debug().Msg("lazy database initialized successfully")
50 }58 }
51 })59 })
52 60
53- if l.err != nil {61+ l.mu.RLock()
54- return nil, fmt.Errorf("database initialization failed: %w", l.err)62+ db, err := l.db, l.err
63+ l.mu.RUnlock()
64+
65+ if err != nil {
66+ return nil, fmt.Errorf("database initialization failed: %w", err)
55 }67 }
56- return l.db, nil68+ return db, nil
57 }69 }
58 70
59 // Close closes the database connection if it was initialized.71 // Close closes the database connection if it was initialized.
M internal/ui/component/omnibox.go +90 -19
@@ -37,6 +37,7 @@ type Suggestion struct {
3737 URL string
3838 Title string
3939 FaviconURL string
40+ IsFavorite bool // Indicates if this URL is also bookmarked as a favorite
4041 }
4142
4243 // Favorite represents a bookmarked URL.
@@ -596,21 +597,46 @@ func (o *Omnibox) performSearch() {
596597 return
597598 }
598599
599- searchInput := usecase.SearchInput{
600- Query: query,
601- Limit: OmniboxListDefaults.MaxResults,
600+ // Run history search and favorite URL fetch in parallel
601+ type searchResult struct {
602+ output *usecase.SearchOutput
603+ err error
602604 }
603- output, err := o.historyUC.Search(ctx, searchInput)
604- if err != nil {
605- log.Error().Err(err).Msg("history search failed")
605+ searchCh := make(chan searchResult, 1)
606+ favCh := make(chan map[string]struct{}, 1)
607+
608+ go func() {
609+ searchInput := usecase.SearchInput{
610+ Query: query,
611+ Limit: OmniboxListDefaults.MaxResults,
612+ }
613+ output, err := o.historyUC.Search(ctx, searchInput)
614+ searchCh <- searchResult{output, err}
615+ }()
616+
617+ go func() {
618+ favCh <- o.getFavoriteURLs(ctx)
619+ }()
620+
621+ // Wait for both results
622+ sr := <-searchCh
623+ favoriteURLs := <-favCh
624+
625+ if sr.err != nil {
626+ log.Error().Err(sr.err).Msg("history search failed")
627+ return
628+ }
629+ if sr.output == nil {
606630 return
607631 }
608632
609- suggestions := make([]Suggestion, 0, len(output.Matches))
610- for _, r := range output.Matches {
633+ suggestions := make([]Suggestion, 0, len(sr.output.Matches))
634+ for _, r := range sr.output.Matches {
635+ _, isFav := favoriteURLs[r.Entry.URL]
611636 suggestions = append(suggestions, Suggestion{
612- URL: r.Entry.URL,
613- Title: r.Entry.Title,
637+ URL: r.Entry.URL,
638+ Title: r.Entry.Title,
639+ IsFavorite: isFav,
614640 })
615641 }
616642
@@ -636,17 +662,43 @@ func (o *Omnibox) loadInitialHistory() {
636662 return
637663
638664 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")
665+ // Run history fetch and favorite URL fetch in parallel
666+ type historyResult struct {
667+ results []*entity.HistoryEntry
668+ err error
669+ }
670+ historyCh := make(chan historyResult, 1)
671+ favCh := make(chan map[string]struct{}, 1)
672+
673+ go func() {
674+ // TODO: Implement GetMostVisited in use case if needed
675+ results, err := o.historyUC.GetRecent(ctx, OmniboxListDefaults.MaxResults, 0)
676+ historyCh <- historyResult{results, err}
677+ }()
678+
679+ go func() {
680+ favCh <- o.getFavoriteURLs(ctx)
681+ }()
682+
683+ // Wait for both results
684+ hr := <-historyCh
685+ favoriteURLs := <-favCh
686+
687+ if hr.err != nil {
688+ log.Error().Err(hr.err).Msg("failed to load recent history")
643689 return
644690 }
645- suggestions = make([]Suggestion, 0, len(results))
646- for _, r := range results {
691+ if hr.results == nil {
692+ return
693+ }
694+
695+ suggestions = make([]Suggestion, 0, len(hr.results))
696+ for _, r := range hr.results {
697+ _, isFav := favoriteURLs[r.URL]
647698 suggestions = append(suggestions, Suggestion{
648- URL: r.URL,
649- Title: r.Title,
699+ URL: r.URL,
700+ Title: r.Title,
701+ IsFavorite: isFav,
650702 })
651703 }
652704 }
@@ -898,7 +950,11 @@ func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index
898950
899951 // createSuggestionRow creates a ListBoxRow for a suggestion.
900952 func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow {
901- return o.createRowWithFavicon(s.URL, s.Title, "web-browser-symbolic", index)
953+ row := o.createRowWithFavicon(s.URL, s.Title, "web-browser-symbolic", index)
954+ if row != nil && s.IsFavorite {
955+ row.AddCssClass("omnibox-row-favorite")
956+ }
957+ return row
902958 }
903959
904960 // createFavoriteRow creates a ListBoxRow for a favorite.
@@ -1340,6 +1396,21 @@ func (o *Omnibox) idleAddUpdateSuggestions(suggestions []Suggestion) {
13401396 glib.IdleAdd(&cb, 0)
13411397 }
13421398
1399+// getFavoriteURLs returns a set of all favorited URLs for batch lookup.
1400+// Returns empty map on error to avoid blocking history display.
1401+func (o *Omnibox) getFavoriteURLs(ctx context.Context) map[string]struct{} {
1402+ if o.favoritesUC == nil {
1403+ return make(map[string]struct{})
1404+ }
1405+ urls, err := o.favoritesUC.GetAllURLs(ctx)
1406+ if err != nil {
1407+ log := logging.FromContext(ctx)
1408+ log.Warn().Err(err).Msg("failed to load favorite URLs for indicator")
1409+ return make(map[string]struct{})
1410+ }
1411+ return urls
1412+}
1413+
13431414 // idleAddUpdateFavorites schedules updateFavorites on the GTK main thread.
13441415 func (o *Omnibox) idleAddUpdateFavorites(favorites []Favorite) {
13451416 var cb glib.SourceFunc = func(data uintptr) bool {
@@ -37,6 +37,7 @@ type Suggestion struct {
37 URL string37 URL string
38 Title string38 Title string
39 FaviconURL string39 FaviconURL string
40+ IsFavorite bool // Indicates if this URL is also bookmarked as a favorite
40 }41 }
41 42
42 // Favorite represents a bookmarked URL.43 // Favorite represents a bookmarked URL.
@@ -596,21 +597,46 @@ func (o *Omnibox) performSearch() {
596 return597 return
597 }598 }
598 599
599- searchInput := usecase.SearchInput{600+ // Run history search and favorite URL fetch in parallel
600- Query: query,601+ type searchResult struct {
601- Limit: OmniboxListDefaults.MaxResults,602+ output *usecase.SearchOutput
603+ err error
602 }604 }
603- output, err := o.historyUC.Search(ctx, searchInput)605+ searchCh := make(chan searchResult, 1)
604- if err != nil {606+ favCh := make(chan map[string]struct{}, 1)
605- log.Error().Err(err).Msg("history search failed")607+
608+ go func() {
609+ searchInput := usecase.SearchInput{
610+ Query: query,
611+ Limit: OmniboxListDefaults.MaxResults,
612+ }
613+ output, err := o.historyUC.Search(ctx, searchInput)
614+ searchCh <- searchResult{output, err}
615+ }()
616+
617+ go func() {
618+ favCh <- o.getFavoriteURLs(ctx)
619+ }()
620+
621+ // Wait for both results
622+ sr := <-searchCh
623+ favoriteURLs := <-favCh
624+
625+ if sr.err != nil {
626+ log.Error().Err(sr.err).Msg("history search failed")
627+ return
628+ }
629+ if sr.output == nil {
606 return630 return
607 }631 }
608 632
609- suggestions := make([]Suggestion, 0, len(output.Matches))633+ suggestions := make([]Suggestion, 0, len(sr.output.Matches))
610- for _, r := range output.Matches {634+ for _, r := range sr.output.Matches {
635+ _, isFav := favoriteURLs[r.Entry.URL]
611 suggestions = append(suggestions, Suggestion{636 suggestions = append(suggestions, Suggestion{
612- URL: r.Entry.URL,637+ URL: r.Entry.URL,
613- Title: r.Entry.Title,638+ Title: r.Entry.Title,
639+ IsFavorite: isFav,
614 })640 })
615 }641 }
616 642
@@ -636,17 +662,43 @@ func (o *Omnibox) loadInitialHistory() {
636 return662 return
637 663
638 case "most_visited", "recent", "":664 case "most_visited", "recent", "":
639- // TODO: Implement GetMostVisited in use case if needed665+ // Run history fetch and favorite URL fetch in parallel
640- results, err := o.historyUC.GetRecent(ctx, OmniboxListDefaults.MaxResults, 0)666+ type historyResult struct {
641- if err != nil {667+ results []*entity.HistoryEntry
642- log.Error().Err(err).Msg("failed to load recent history")668+ err error
669+ }
670+ historyCh := make(chan historyResult, 1)
671+ favCh := make(chan map[string]struct{}, 1)
672+
673+ go func() {
674+ // TODO: Implement GetMostVisited in use case if needed
675+ results, err := o.historyUC.GetRecent(ctx, OmniboxListDefaults.MaxResults, 0)
676+ historyCh <- historyResult{results, err}
677+ }()
678+
679+ go func() {
680+ favCh <- o.getFavoriteURLs(ctx)
681+ }()
682+
683+ // Wait for both results
684+ hr := <-historyCh
685+ favoriteURLs := <-favCh
686+
687+ if hr.err != nil {
688+ log.Error().Err(hr.err).Msg("failed to load recent history")
643 return689 return
644 }690 }
645- suggestions = make([]Suggestion, 0, len(results))691+ if hr.results == nil {
646- for _, r := range results {692+ return
693+ }
694+
695+ suggestions = make([]Suggestion, 0, len(hr.results))
696+ for _, r := range hr.results {
697+ _, isFav := favoriteURLs[r.URL]
647 suggestions = append(suggestions, Suggestion{698 suggestions = append(suggestions, Suggestion{
648- URL: r.URL,699+ URL: r.URL,
649- Title: r.Title,700+ Title: r.Title,
701+ IsFavorite: isFav,
650 })702 })
651 }703 }
652 }704 }
@@ -898,7 +950,11 @@ func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index
898 950
899 // createSuggestionRow creates a ListBoxRow for a suggestion.951 // createSuggestionRow creates a ListBoxRow for a suggestion.
900 func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow {952 func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow {
901- return o.createRowWithFavicon(s.URL, s.Title, "web-browser-symbolic", index)953+ row := o.createRowWithFavicon(s.URL, s.Title, "web-browser-symbolic", index)
954+ if row != nil && s.IsFavorite {
955+ row.AddCssClass("omnibox-row-favorite")
956+ }
957+ return row
902 }958 }
903 959
904 // createFavoriteRow creates a ListBoxRow for a favorite.960 // createFavoriteRow creates a ListBoxRow for a favorite.
@@ -1340,6 +1396,21 @@ func (o *Omnibox) idleAddUpdateSuggestions(suggestions []Suggestion) {
1340 glib.IdleAdd(&cb, 0)1396 glib.IdleAdd(&cb, 0)
1341 }1397 }
1342 1398
1399+// getFavoriteURLs returns a set of all favorited URLs for batch lookup.
1400+// Returns empty map on error to avoid blocking history display.
1401+func (o *Omnibox) getFavoriteURLs(ctx context.Context) map[string]struct{} {
1402+ if o.favoritesUC == nil {
1403+ return make(map[string]struct{})
1404+ }
1405+ urls, err := o.favoritesUC.GetAllURLs(ctx)
1406+ if err != nil {
1407+ log := logging.FromContext(ctx)
1408+ log.Warn().Err(err).Msg("failed to load favorite URLs for indicator")
1409+ return make(map[string]struct{})
1410+ }
1411+ return urls
1412+}
1413+
1343 // idleAddUpdateFavorites schedules updateFavorites on the GTK main thread.1414 // idleAddUpdateFavorites schedules updateFavorites on the GTK main thread.
1344 func (o *Omnibox) idleAddUpdateFavorites(favorites []Favorite) {1415 func (o *Omnibox) idleAddUpdateFavorites(favorites []Favorite) {
1345 var cb glib.SourceFunc = func(data uintptr) bool {1416 var cb glib.SourceFunc = func(data uintptr) bool {
M internal/ui/theme/css.go +16 -0
@@ -299,6 +299,22 @@ entry.omnibox-entry > *:focus-visible {
299299 border-left: 0.1875em solid var(--accent);
300300 }
301301
302+/* Favorite indicator in history mode - yellowish accent */
303+.omnibox-row.omnibox-row-favorite {
304+ background-color: alpha(var(--warning), 0.08);
305+ border-left: 0.1875em solid var(--warning);
306+}
307+
308+.omnibox-row.omnibox-row-favorite:hover {
309+ background-color: alpha(var(--warning), 0.15);
310+ border-left: 0.1875em solid var(--warning);
311+}
312+
313+.omnibox-row.omnibox-row-favorite:selected {
314+ background-color: alpha(var(--warning), 0.2);
315+ border-left: 0.1875em solid var(--warning);
316+}
317+
302318 /* Favicon in omnibox rows */
303319 .omnibox-favicon {
304320 min-width: 1em;
@@ -299,6 +299,22 @@ entry.omnibox-entry > *:focus-visible {
299 border-left: 0.1875em solid var(--accent);299 border-left: 0.1875em solid var(--accent);
300 }300 }
301 301
302+/* Favorite indicator in history mode - yellowish accent */
303+.omnibox-row.omnibox-row-favorite {
304+ background-color: alpha(var(--warning), 0.08);
305+ border-left: 0.1875em solid var(--warning);
306+}
307+
308+.omnibox-row.omnibox-row-favorite:hover {
309+ background-color: alpha(var(--warning), 0.15);
310+ border-left: 0.1875em solid var(--warning);
311+}
312+
313+.omnibox-row.omnibox-row-favorite:selected {
314+ background-color: alpha(var(--warning), 0.2);
315+ border-left: 0.1875em solid var(--warning);
316+}
317+
302 /* Favicon in omnibox rows */318 /* Favicon in omnibox rows */
303 .omnibox-favicon {319 .omnibox-favicon {
304 min-width: 1em;320 min-width: 1em;

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