Merge pull request #51 from bnema/feature/omnibox-favorite-indicator
feat(omnibox): add favorite indicator for history items
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. | |||
| 5 | 5 | ## [Unreleased] | |
| 6 | 6 | ||
| 7 | 7 | ### 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 | 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 | 10 | - **Config migration system**: Detects missing config keys when new settings are added in updates and provides tools to add them. | |
| 10 | 11 | - `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 | ### Added | 7 | ### 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 | |||
| 196 | 196 | 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 | 218 | // GetTree builds a complete hierarchical view of folders and favorites. | |
| 200 | 219 | func (uc *ManageFavoritesUseCase) GetTree(ctx context.Context) (*entity.FavoriteTree, error) { | |
| 201 | 220 | log := logging.FromContext(ctx) | |
| @@ -196,6 +196,25 @@ func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, url string) (b | |||||
| 196 | return fav != nil, nil | 196 | 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( | |||
| 89 | 89 | 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 | 96 | persistFn := func(persistCtx context.Context) error { | |
| 93 | 97 | persistMu.Do(func() { | |
| 94 | 98 | saveCtx := corelogging.WithContext(context.Background(), logger) | |
| @@ -89,6 +89,10 @@ func StartBrowserSession( | |||||
| 89 | persistErr error | 89 | 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) { | |||
| 42 | 42 | log := logging.FromContext(ctx) | |
| 43 | 43 | log.Debug().Str("path", l.dbPath).Msg("lazy database initialization starting") | |
| 44 | 44 | ||
| 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") | |
| 48 | 56 | } else { | |
| 49 | 57 | log.Debug().Msg("lazy database initialized successfully") | |
| 50 | 58 | } | |
| 51 | 59 | }) | |
| 52 | 60 | ||
| 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) | |
| 55 | 67 | } | |
| 56 | - | return l.db, nil | |
| 68 | + | return db, nil | |
| 57 | 69 | } | |
| 58 | 70 | ||
| 59 | 71 | // 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, nil | 68 | + | 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 { | |||
| 37 | 37 | URL string | |
| 38 | 38 | Title string | |
| 39 | 39 | FaviconURL string | |
| 40 | + | IsFavorite bool // Indicates if this URL is also bookmarked as a favorite | |
| 40 | 41 | } | |
| 41 | 42 | ||
| 42 | 43 | // Favorite represents a bookmarked URL. | |
| @@ -596,21 +597,46 @@ func (o *Omnibox) performSearch() { | |||
| 596 | 597 | return | |
| 597 | 598 | } | |
| 598 | 599 | ||
| 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 | |
| 602 | 604 | } | |
| 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 { | |
| 606 | 630 | return | |
| 607 | 631 | } | |
| 608 | 632 | ||
| 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] | |
| 611 | 636 | 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, | |
| 614 | 640 | }) | |
| 615 | 641 | } | |
| 616 | 642 | ||
| @@ -636,17 +662,43 @@ func (o *Omnibox) loadInitialHistory() { | |||
| 636 | 662 | return | |
| 637 | 663 | ||
| 638 | 664 | 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") | |
| 643 | 689 | return | |
| 644 | 690 | } | |
| 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] | |
| 647 | 698 | suggestions = append(suggestions, Suggestion{ | |
| 648 | - | URL: r.URL, | |
| 649 | - | Title: r.Title, | |
| 699 | + | URL: r.URL, | |
| 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 | 951 | // createSuggestionRow creates a ListBoxRow for a suggestion. | |
| 900 | 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 | 960 | // createFavoriteRow creates a ListBoxRow for a favorite. | |
| @@ -1340,6 +1396,21 @@ func (o *Omnibox) idleAddUpdateSuggestions(suggestions []Suggestion) { | |||
| 1340 | 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 | 1414 | // idleAddUpdateFavorites schedules updateFavorites on the GTK main thread. | |
| 1344 | 1415 | func (o *Omnibox) idleAddUpdateFavorites(favorites []Favorite) { | |
| 1345 | 1416 | var cb glib.SourceFunc = func(data uintptr) bool { | |
| @@ -37,6 +37,7 @@ type Suggestion struct { | |||||
| 37 | URL string | 37 | URL string | ||
| 38 | Title string | 38 | Title string | ||
| 39 | FaviconURL string | 39 | 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 | return | 597 | 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 | return | 630 | 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 | return | 662 | return | ||
| 637 | 663 | ||||
| 638 | case "most_visited", "recent", "": | 664 | case "most_visited", "recent", "": | ||
| 639 | - | // TODO: Implement GetMostVisited in use case if needed | 665 | + | // 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 | return | 689 | 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 { | |||
| 299 | 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 | 318 | /* Favicon in omnibox rows */ | |
| 303 | 319 | .omnibox-favicon { | |
| 304 | 320 | 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; | ||