fix(omnibox): add reactive favorite toggle with toast notifications
- commit
- ff5b26e09ea1d67233f3c46afd47c802c8c0c92f
- author
- bnema · · 2025-12-28 17:49 UTC
- parent
- 2391e76
- signature
- GPG Unverified
2B82207D370127A0The key is unknown
5 files · +245 -21
M CHANGELOG.md +1 -0
| @@ -40,6 +40,7 @@ All notable changes to this project will be documented in this file. | |||
| 40 | 40 | - **Console logging**: Standardized console timestamps to `HH:MM:SS` across bootstrap and session logging. | |
| 41 | 41 | ||
| 42 | 42 | ### Fixed | |
| 43 | + | - **Omnibox favorite toggle reactivity**: Fixed space key toggle in omnibox not updating UI reactively. Now properly toggles favorites (add if not favorite, remove if favorite) with immediate visual feedback (yellow indicator) and toast notifications. Business logic moved to `ManageFavoritesUseCase.Toggle()` for testability. | |
| 43 | 44 | - **Config migration type matching**: Fixed migration incorrectly matching keys of different types (e.g., `_width` int keys with `_color` string keys) during rename detection, preventing value swapping. | |
| 44 | 45 | - **Fullscreen video tab bar**: Fixed tab bar remaining visible during fullscreen video playback. Now hides automatically when entering fullscreen and restores based on normal visibility logic when exiting. | |
| 45 | 46 | - **Dark mode navigation flash**: Fixed a brief white flash between the loading skeleton and page content when navigating in dark mode. | |
| @@ -40,6 +40,7 @@ All notable changes to this project will be documented in this file. | |||||
| 40 | - **Console logging**: Standardized console timestamps to `HH:MM:SS` across bootstrap and session logging. | 40 | - **Console logging**: Standardized console timestamps to `HH:MM:SS` across bootstrap and session logging. | ||
| 41 | 41 | ||||
| 42 | ### Fixed | 42 | ### Fixed | ||
| 43 | + | - **Omnibox favorite toggle reactivity**: Fixed space key toggle in omnibox not updating UI reactively. Now properly toggles favorites (add if not favorite, remove if favorite) with immediate visual feedback (yellow indicator) and toast notifications. Business logic moved to `ManageFavoritesUseCase.Toggle()` for testability. | |||
| 43 | - **Config migration type matching**: Fixed migration incorrectly matching keys of different types (e.g., `_width` int keys with `_color` string keys) during rename detection, preventing value swapping. | 44 | - **Config migration type matching**: Fixed migration incorrectly matching keys of different types (e.g., `_width` int keys with `_color` string keys) during rename detection, preventing value swapping. | ||
| 44 | - **Fullscreen video tab bar**: Fixed tab bar remaining visible during fullscreen video playback. Now hides automatically when entering fullscreen and restores based on normal visibility logic when exiting. | 45 | - **Fullscreen video tab bar**: Fixed tab bar remaining visible during fullscreen video playback. Now hides automatically when entering fullscreen and restores based on normal visibility logic when exiting. | ||
| 45 | - **Dark mode navigation flash**: Fixed a brief white flash between the loading skeleton and page content when navigating in dark mode. | 46 | - **Dark mode navigation flash**: Fixed a brief white flash between the loading skeleton and page content when navigating in dark mode. | ||
M internal/application/usecase/manage_favorites.go +52 -0
| @@ -196,6 +196,58 @@ func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, url string) (b | |||
| 196 | 196 | return fav != nil, nil | |
| 197 | 197 | } | |
| 198 | 198 | ||
| 199 | + | // ToggleResult indicates the result of a toggle operation. | |
| 200 | + | type ToggleResult struct { | |
| 201 | + | Added bool // true if favorite was added, false if removed | |
| 202 | + | URL string | |
| 203 | + | Title string | |
| 204 | + | Message string // User-friendly message | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | // Toggle adds or removes a URL from favorites based on current state. | |
| 208 | + | // If the URL is already a favorite, it removes it. Otherwise, it adds it. | |
| 209 | + | func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, url, title string) (*ToggleResult, error) { | |
| 210 | + | log := logging.FromContext(ctx) | |
| 211 | + | log.Debug().Str("url", url).Msg("toggling favorite") | |
| 212 | + | ||
| 213 | + | if url == "" { | |
| 214 | + | return nil, fmt.Errorf("URL cannot be empty") | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | // Check if already favorited | |
| 218 | + | existing, err := uc.favoriteRepo.FindByURL(ctx, url) | |
| 219 | + | if err != nil { | |
| 220 | + | return nil, fmt.Errorf("failed to check existing favorite: %w", err) | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | if existing != nil { | |
| 224 | + | // Remove favorite | |
| 225 | + | if err := uc.favoriteRepo.Delete(ctx, existing.ID); err != nil { | |
| 226 | + | return nil, fmt.Errorf("failed to remove favorite: %w", err) | |
| 227 | + | } | |
| 228 | + | log.Info().Str("url", url).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle") | |
| 229 | + | return &ToggleResult{ | |
| 230 | + | Added: false, | |
| 231 | + | URL: url, | |
| 232 | + | Title: existing.Title, | |
| 233 | + | Message: "Favorite removed", | |
| 234 | + | }, nil | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | // Add favorite | |
| 238 | + | fav := entity.NewFavorite(url, title) | |
| 239 | + | if err := uc.favoriteRepo.Save(ctx, fav); err != nil { | |
| 240 | + | return nil, fmt.Errorf("failed to add favorite: %w", err) | |
| 241 | + | } | |
| 242 | + | log.Info().Str("url", url).Int64("id", int64(fav.ID)).Msg("favorite added via toggle") | |
| 243 | + | return &ToggleResult{ | |
| 244 | + | Added: true, | |
| 245 | + | URL: url, | |
| 246 | + | Title: title, | |
| 247 | + | Message: "Favorite added", | |
| 248 | + | }, nil | |
| 249 | + | } | |
| 250 | + | ||
| 199 | 251 | // GetAllURLs returns a set of all favorited URLs for efficient batch lookup. | |
| 200 | 252 | func (uc *ManageFavoritesUseCase) GetAllURLs(ctx context.Context) (map[string]struct{}, error) { | |
| 201 | 253 | log := logging.FromContext(ctx) | |
| @@ -196,6 +196,58 @@ 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 | + | // ToggleResult indicates the result of a toggle operation. | |||
| 200 | + | type ToggleResult struct { | |||
| 201 | + | Added bool // true if favorite was added, false if removed | |||
| 202 | + | URL string | |||
| 203 | + | Title string | |||
| 204 | + | Message string // User-friendly message | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | // Toggle adds or removes a URL from favorites based on current state. | |||
| 208 | + | // If the URL is already a favorite, it removes it. Otherwise, it adds it. | |||
| 209 | + | func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, url, title string) (*ToggleResult, error) { | |||
| 210 | + | log := logging.FromContext(ctx) | |||
| 211 | + | log.Debug().Str("url", url).Msg("toggling favorite") | |||
| 212 | + | ||||
| 213 | + | if url == "" { | |||
| 214 | + | return nil, fmt.Errorf("URL cannot be empty") | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | // Check if already favorited | |||
| 218 | + | existing, err := uc.favoriteRepo.FindByURL(ctx, url) | |||
| 219 | + | if err != nil { | |||
| 220 | + | return nil, fmt.Errorf("failed to check existing favorite: %w", err) | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | if existing != nil { | |||
| 224 | + | // Remove favorite | |||
| 225 | + | if err := uc.favoriteRepo.Delete(ctx, existing.ID); err != nil { | |||
| 226 | + | return nil, fmt.Errorf("failed to remove favorite: %w", err) | |||
| 227 | + | } | |||
| 228 | + | log.Info().Str("url", url).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle") | |||
| 229 | + | return &ToggleResult{ | |||
| 230 | + | Added: false, | |||
| 231 | + | URL: url, | |||
| 232 | + | Title: existing.Title, | |||
| 233 | + | Message: "Favorite removed", | |||
| 234 | + | }, nil | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | // Add favorite | |||
| 238 | + | fav := entity.NewFavorite(url, title) | |||
| 239 | + | if err := uc.favoriteRepo.Save(ctx, fav); err != nil { | |||
| 240 | + | return nil, fmt.Errorf("failed to add favorite: %w", err) | |||
| 241 | + | } | |||
| 242 | + | log.Info().Str("url", url).Int64("id", int64(fav.ID)).Msg("favorite added via toggle") | |||
| 243 | + | return &ToggleResult{ | |||
| 244 | + | Added: true, | |||
| 245 | + | URL: url, | |||
| 246 | + | Title: title, | |||
| 247 | + | Message: "Favorite added", | |||
| 248 | + | }, nil | |||
| 249 | + | } | |||
| 250 | + | ||||
| 199 | // GetAllURLs returns a set of all favorited URLs for efficient batch lookup. | 251 | // GetAllURLs returns a set of all favorited URLs for efficient batch lookup. | ||
| 200 | func (uc *ManageFavoritesUseCase) GetAllURLs(ctx context.Context) (map[string]struct{}, error) { | 252 | func (uc *ManageFavoritesUseCase) GetAllURLs(ctx context.Context) (map[string]struct{}, error) { | ||
| 201 | log := logging.FromContext(ctx) | 253 | log := logging.FromContext(ctx) | ||
M internal/application/usecase/manage_favorites_test.go +123 -0
| @@ -75,3 +75,126 @@ func TestManageFavoritesUseCase_GetAllURLs_ReturnsErrorOnRepoFailure(t *testing. | |||
| 75 | 75 | assert.Nil(t, urls) | |
| 76 | 76 | assert.Contains(t, err.Error(), "failed to get favorites") | |
| 77 | 77 | } | |
| 78 | + | ||
| 79 | + | func TestManageFavoritesUseCase_Toggle_AddsWhenNotFavorite(t *testing.T) { | |
| 80 | + | ctx := testContext() | |
| 81 | + | ||
| 82 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |
| 83 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |
| 84 | + | tagRepo := repomocks.NewMockTagRepository(t) | |
| 85 | + | ||
| 86 | + | // URL is not a favorite | |
| 87 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil) | |
| 88 | + | favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(f *entity.Favorite) bool { | |
| 89 | + | return f.URL == "https://example.com" && f.Title == "Example" | |
| 90 | + | })).Return(nil) | |
| 91 | + | ||
| 92 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |
| 93 | + | ||
| 94 | + | result, err := uc.Toggle(ctx, "https://example.com", "Example") | |
| 95 | + | require.NoError(t, err) | |
| 96 | + | require.NotNil(t, result) | |
| 97 | + | ||
| 98 | + | assert.True(t, result.Added) | |
| 99 | + | assert.Equal(t, "https://example.com", result.URL) | |
| 100 | + | assert.Equal(t, "Example", result.Title) | |
| 101 | + | assert.Equal(t, "Favorite added", result.Message) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func TestManageFavoritesUseCase_Toggle_RemovesWhenAlreadyFavorite(t *testing.T) { | |
| 105 | + | ctx := testContext() | |
| 106 | + | ||
| 107 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |
| 108 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |
| 109 | + | tagRepo := repomocks.NewMockTagRepository(t) | |
| 110 | + | ||
| 111 | + | existingFav := &entity.Favorite{ | |
| 112 | + | ID: 42, | |
| 113 | + | URL: "https://example.com", | |
| 114 | + | Title: "Example Site", | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // URL is already a favorite | |
| 118 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(existingFav, nil) | |
| 119 | + | favoriteRepo.EXPECT().Delete(mock.Anything, entity.FavoriteID(42)).Return(nil) | |
| 120 | + | ||
| 121 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |
| 122 | + | ||
| 123 | + | result, err := uc.Toggle(ctx, "https://example.com", "Example") | |
| 124 | + | require.NoError(t, err) | |
| 125 | + | require.NotNil(t, result) | |
| 126 | + | ||
| 127 | + | assert.False(t, result.Added) | |
| 128 | + | assert.Equal(t, "https://example.com", result.URL) | |
| 129 | + | assert.Equal(t, "Example Site", result.Title) // Uses existing title | |
| 130 | + | assert.Equal(t, "Favorite removed", result.Message) | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func TestManageFavoritesUseCase_Toggle_EmptyURLReturnsError(t *testing.T) { | |
| 134 | + | ctx := testContext() | |
| 135 | + | ||
| 136 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |
| 137 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |
| 138 | + | tagRepo := repomocks.NewMockTagRepository(t) | |
| 139 | + | ||
| 140 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |
| 141 | + | ||
| 142 | + | result, err := uc.Toggle(ctx, "", "Title") | |
| 143 | + | require.Error(t, err) | |
| 144 | + | assert.Nil(t, result) | |
| 145 | + | assert.Contains(t, err.Error(), "URL cannot be empty") | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func TestManageFavoritesUseCase_Toggle_FindByURLErrorReturnsError(t *testing.T) { | |
| 149 | + | ctx := testContext() | |
| 150 | + | ||
| 151 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |
| 152 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |
| 153 | + | tagRepo := repomocks.NewMockTagRepository(t) | |
| 154 | + | ||
| 155 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, errors.New("db error")) | |
| 156 | + | ||
| 157 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |
| 158 | + | ||
| 159 | + | result, err := uc.Toggle(ctx, "https://example.com", "Example") | |
| 160 | + | require.Error(t, err) | |
| 161 | + | assert.Nil(t, result) | |
| 162 | + | assert.Contains(t, err.Error(), "failed to check existing favorite") | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func TestManageFavoritesUseCase_Toggle_SaveErrorReturnsError(t *testing.T) { | |
| 166 | + | ctx := testContext() | |
| 167 | + | ||
| 168 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |
| 169 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |
| 170 | + | tagRepo := repomocks.NewMockTagRepository(t) | |
| 171 | + | ||
| 172 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil) | |
| 173 | + | favoriteRepo.EXPECT().Save(mock.Anything, mock.Anything).Return(errors.New("db error")) | |
| 174 | + | ||
| 175 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |
| 176 | + | ||
| 177 | + | result, err := uc.Toggle(ctx, "https://example.com", "Example") | |
| 178 | + | require.Error(t, err) | |
| 179 | + | assert.Nil(t, result) | |
| 180 | + | assert.Contains(t, err.Error(), "failed to add favorite") | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func TestManageFavoritesUseCase_Toggle_DeleteErrorReturnsError(t *testing.T) { | |
| 184 | + | ctx := testContext() | |
| 185 | + | ||
| 186 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |
| 187 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |
| 188 | + | tagRepo := repomocks.NewMockTagRepository(t) | |
| 189 | + | ||
| 190 | + | existingFav := &entity.Favorite{ID: 42, URL: "https://example.com", Title: "Example"} | |
| 191 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(existingFav, nil) | |
| 192 | + | favoriteRepo.EXPECT().Delete(mock.Anything, entity.FavoriteID(42)).Return(errors.New("db error")) | |
| 193 | + | ||
| 194 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |
| 195 | + | ||
| 196 | + | result, err := uc.Toggle(ctx, "https://example.com", "Example") | |
| 197 | + | require.Error(t, err) | |
| 198 | + | assert.Nil(t, result) | |
| 199 | + | assert.Contains(t, err.Error(), "failed to remove favorite") | |
| 200 | + | } | |
| @@ -75,3 +75,126 @@ func TestManageFavoritesUseCase_GetAllURLs_ReturnsErrorOnRepoFailure(t *testing. | |||||
| 75 | assert.Nil(t, urls) | 75 | assert.Nil(t, urls) | ||
| 76 | assert.Contains(t, err.Error(), "failed to get favorites") | 76 | assert.Contains(t, err.Error(), "failed to get favorites") | ||
| 77 | } | 77 | } | ||
| 78 | + | ||||
| 79 | + | func TestManageFavoritesUseCase_Toggle_AddsWhenNotFavorite(t *testing.T) { | |||
| 80 | + | ctx := testContext() | |||
| 81 | + | ||||
| 82 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |||
| 83 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 84 | + | tagRepo := repomocks.NewMockTagRepository(t) | |||
| 85 | + | ||||
| 86 | + | // URL is not a favorite | |||
| 87 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil) | |||
| 88 | + | favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(f *entity.Favorite) bool { | |||
| 89 | + | return f.URL == "https://example.com" && f.Title == "Example" | |||
| 90 | + | })).Return(nil) | |||
| 91 | + | ||||
| 92 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |||
| 93 | + | ||||
| 94 | + | result, err := uc.Toggle(ctx, "https://example.com", "Example") | |||
| 95 | + | require.NoError(t, err) | |||
| 96 | + | require.NotNil(t, result) | |||
| 97 | + | ||||
| 98 | + | assert.True(t, result.Added) | |||
| 99 | + | assert.Equal(t, "https://example.com", result.URL) | |||
| 100 | + | assert.Equal(t, "Example", result.Title) | |||
| 101 | + | assert.Equal(t, "Favorite added", result.Message) | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func TestManageFavoritesUseCase_Toggle_RemovesWhenAlreadyFavorite(t *testing.T) { | |||
| 105 | + | ctx := testContext() | |||
| 106 | + | ||||
| 107 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |||
| 108 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 109 | + | tagRepo := repomocks.NewMockTagRepository(t) | |||
| 110 | + | ||||
| 111 | + | existingFav := &entity.Favorite{ | |||
| 112 | + | ID: 42, | |||
| 113 | + | URL: "https://example.com", | |||
| 114 | + | Title: "Example Site", | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | // URL is already a favorite | |||
| 118 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(existingFav, nil) | |||
| 119 | + | favoriteRepo.EXPECT().Delete(mock.Anything, entity.FavoriteID(42)).Return(nil) | |||
| 120 | + | ||||
| 121 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |||
| 122 | + | ||||
| 123 | + | result, err := uc.Toggle(ctx, "https://example.com", "Example") | |||
| 124 | + | require.NoError(t, err) | |||
| 125 | + | require.NotNil(t, result) | |||
| 126 | + | ||||
| 127 | + | assert.False(t, result.Added) | |||
| 128 | + | assert.Equal(t, "https://example.com", result.URL) | |||
| 129 | + | assert.Equal(t, "Example Site", result.Title) // Uses existing title | |||
| 130 | + | assert.Equal(t, "Favorite removed", result.Message) | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func TestManageFavoritesUseCase_Toggle_EmptyURLReturnsError(t *testing.T) { | |||
| 134 | + | ctx := testContext() | |||
| 135 | + | ||||
| 136 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |||
| 137 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 138 | + | tagRepo := repomocks.NewMockTagRepository(t) | |||
| 139 | + | ||||
| 140 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |||
| 141 | + | ||||
| 142 | + | result, err := uc.Toggle(ctx, "", "Title") | |||
| 143 | + | require.Error(t, err) | |||
| 144 | + | assert.Nil(t, result) | |||
| 145 | + | assert.Contains(t, err.Error(), "URL cannot be empty") | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func TestManageFavoritesUseCase_Toggle_FindByURLErrorReturnsError(t *testing.T) { | |||
| 149 | + | ctx := testContext() | |||
| 150 | + | ||||
| 151 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |||
| 152 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 153 | + | tagRepo := repomocks.NewMockTagRepository(t) | |||
| 154 | + | ||||
| 155 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, errors.New("db error")) | |||
| 156 | + | ||||
| 157 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |||
| 158 | + | ||||
| 159 | + | result, err := uc.Toggle(ctx, "https://example.com", "Example") | |||
| 160 | + | require.Error(t, err) | |||
| 161 | + | assert.Nil(t, result) | |||
| 162 | + | assert.Contains(t, err.Error(), "failed to check existing favorite") | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func TestManageFavoritesUseCase_Toggle_SaveErrorReturnsError(t *testing.T) { | |||
| 166 | + | ctx := testContext() | |||
| 167 | + | ||||
| 168 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |||
| 169 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 170 | + | tagRepo := repomocks.NewMockTagRepository(t) | |||
| 171 | + | ||||
| 172 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil) | |||
| 173 | + | favoriteRepo.EXPECT().Save(mock.Anything, mock.Anything).Return(errors.New("db error")) | |||
| 174 | + | ||||
| 175 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |||
| 176 | + | ||||
| 177 | + | result, err := uc.Toggle(ctx, "https://example.com", "Example") | |||
| 178 | + | require.Error(t, err) | |||
| 179 | + | assert.Nil(t, result) | |||
| 180 | + | assert.Contains(t, err.Error(), "failed to add favorite") | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | func TestManageFavoritesUseCase_Toggle_DeleteErrorReturnsError(t *testing.T) { | |||
| 184 | + | ctx := testContext() | |||
| 185 | + | ||||
| 186 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |||
| 187 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 188 | + | tagRepo := repomocks.NewMockTagRepository(t) | |||
| 189 | + | ||||
| 190 | + | existingFav := &entity.Favorite{ID: 42, URL: "https://example.com", Title: "Example"} | |||
| 191 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(existingFav, nil) | |||
| 192 | + | favoriteRepo.EXPECT().Delete(mock.Anything, entity.FavoriteID(42)).Return(errors.New("db error")) | |||
| 193 | + | ||||
| 194 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |||
| 195 | + | ||||
| 196 | + | result, err := uc.Toggle(ctx, "https://example.com", "Example") | |||
| 197 | + | require.Error(t, err) | |||
| 198 | + | assert.Nil(t, result) | |||
| 199 | + | assert.Contains(t, err.Error(), "failed to remove favorite") | |||
| 200 | + | } | |||
M internal/ui/component/omnibox.go +67 -19
| @@ -98,7 +98,7 @@ type Omnibox struct { | |||
| 98 | 98 | // Callbacks | |
| 99 | 99 | onNavigate func(url string) | |
| 100 | 100 | onClose func() | |
| 101 | - | onToast func(message string) | |
| 101 | + | onToast func(ctx context.Context, message string, level ToastLevel) | |
| 102 | 102 | ||
| 103 | 103 | // Debouncing | |
| 104 | 104 | debounceTimer *time.Timer | |
| @@ -129,9 +129,9 @@ type OmniboxConfig struct { | |||
| 129 | 129 | ShortcutsUC *usecase.SearchShortcutsUseCase | |
| 130 | 130 | DefaultSearch string | |
| 131 | 131 | InitialBehavior string | |
| 132 | - | UIScale float64 // UI scale for favicon sizing | |
| 133 | - | OnNavigate func(url string) // Callback when user navigates via omnibox | |
| 134 | - | OnToast func(message string) // Callback to show toast notification | |
| 132 | + | UIScale float64 // UI scale for favicon sizing | |
| 133 | + | OnNavigate func(url string) // Callback when user navigates via omnibox | |
| 134 | + | OnToast func(ctx context.Context, message string, level ToastLevel) // Callback to show toast notification | |
| 135 | 135 | } | |
| 136 | 136 | ||
| 137 | 137 | // NewOmnibox creates a new native GTK4 omnibox widget. | |
| @@ -1291,15 +1291,15 @@ func (o *Omnibox) toggleFavorite() { | |||
| 1291 | 1291 | } | |
| 1292 | 1292 | ||
| 1293 | 1293 | if mode == ViewModeHistory { | |
| 1294 | - | // Add to favorites | |
| 1294 | + | // Toggle favorite in history mode | |
| 1295 | 1295 | if idx < 0 || idx >= len(suggestions) { | |
| 1296 | - | log.Debug().Int("index", idx).Msg("add favorite: invalid selection") | |
| 1296 | + | log.Debug().Int("index", idx).Msg("toggle favorite: invalid selection") | |
| 1297 | 1297 | return | |
| 1298 | 1298 | } | |
| 1299 | 1299 | ||
| 1300 | 1300 | s := suggestions[idx] | |
| 1301 | 1301 | if s.URL == "" { | |
| 1302 | - | log.Debug().Msg("add favorite: empty URL") | |
| 1302 | + | log.Debug().Msg("toggle favorite: empty URL") | |
| 1303 | 1303 | return | |
| 1304 | 1304 | } | |
| 1305 | 1305 | ||
| @@ -1307,20 +1307,39 @@ func (o *Omnibox) toggleFavorite() { | |||
| 1307 | 1307 | ctx := o.ctx | |
| 1308 | 1308 | goLog := logging.FromContext(ctx) | |
| 1309 | 1309 | ||
| 1310 | - | goLog.Debug().Str("url", s.URL).Str("title", s.Title).Msg("adding to favorites") | |
| 1311 | - | ||
| 1312 | - | addInput := usecase.AddFavoriteInput{ | |
| 1313 | - | URL: s.URL, | |
| 1314 | - | Title: s.Title, | |
| 1315 | - | } | |
| 1316 | - | ||
| 1317 | - | fav, err := o.favoritesUC.Add(ctx, addInput) | |
| 1310 | + | result, err := o.favoritesUC.Toggle(ctx, s.URL, s.Title) | |
| 1318 | 1311 | if err != nil { | |
| 1319 | - | goLog.Error().Err(err).Str("url", s.URL).Msg("failed to add favorite") | |
| 1312 | + | goLog.Error().Err(err).Str("url", s.URL).Msg("failed to toggle favorite") | |
| 1313 | + | if o.onToast != nil { | |
| 1314 | + | msg := "Failed to add favorite" | |
| 1315 | + | if s.IsFavorite { | |
| 1316 | + | msg = "Failed to remove favorite" | |
| 1317 | + | } | |
| 1318 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 1319 | + | o.onToast(ctx, msg, ToastError) | |
| 1320 | + | return false | |
| 1321 | + | }) | |
| 1322 | + | glib.IdleAdd(&cb, 0) | |
| 1323 | + | } | |
| 1320 | 1324 | return | |
| 1321 | 1325 | } | |
| 1322 | 1326 | ||
| 1323 | - | goLog.Info().Str("url", s.URL).Int64("id", int64(fav.ID)).Msg("favorite added from omnibox") | |
| 1327 | + | // Update suggestion state | |
| 1328 | + | o.mu.Lock() | |
| 1329 | + | if idx < len(o.suggestions) && o.suggestions[idx].URL == s.URL { | |
| 1330 | + | o.suggestions[idx].IsFavorite = result.Added | |
| 1331 | + | } | |
| 1332 | + | o.mu.Unlock() | |
| 1333 | + | ||
| 1334 | + | // Update row CSS and show toast on GTK main thread | |
| 1335 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 1336 | + | o.updateRowFavoriteIndicator(idx, result.Added) | |
| 1337 | + | if o.onToast != nil { | |
| 1338 | + | o.onToast(ctx, result.Message, ToastSuccess) | |
| 1339 | + | } | |
| 1340 | + | return false | |
| 1341 | + | }) | |
| 1342 | + | glib.IdleAdd(&cb, 0) | |
| 1324 | 1343 | }() | |
| 1325 | 1344 | } else { | |
| 1326 | 1345 | // Remove from favorites | |
| @@ -1344,17 +1363,46 @@ func (o *Omnibox) toggleFavorite() { | |||
| 1344 | 1363 | err := o.favoritesUC.Remove(ctx, entity.FavoriteID(f.ID)) | |
| 1345 | 1364 | if err != nil { | |
| 1346 | 1365 | log.Error().Err(err).Int64("id", f.ID).Msg("failed to remove favorite") | |
| 1366 | + | // Show error toast | |
| 1367 | + | if o.onToast != nil { | |
| 1368 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 1369 | + | o.onToast(ctx, "Failed to remove favorite", ToastError) | |
| 1370 | + | return false | |
| 1371 | + | }) | |
| 1372 | + | glib.IdleAdd(&cb, 0) | |
| 1373 | + | } | |
| 1347 | 1374 | return | |
| 1348 | 1375 | } | |
| 1349 | 1376 | ||
| 1350 | 1377 | log.Info().Int64("id", f.ID).Str("url", f.URL).Msg("favorite removed from omnibox") | |
| 1351 | 1378 | ||
| 1352 | - | // Refresh favorites list | |
| 1379 | + | // Refresh favorites list and show toast | |
| 1353 | 1380 | o.loadFavorites(o.entry.GetText()) | |
| 1381 | + | if o.onToast != nil { | |
| 1382 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 1383 | + | o.onToast(ctx, "Favorite removed", ToastSuccess) | |
| 1384 | + | return false | |
| 1385 | + | }) | |
| 1386 | + | glib.IdleAdd(&cb, 0) | |
| 1387 | + | } | |
| 1354 | 1388 | }() | |
| 1355 | 1389 | } | |
| 1356 | 1390 | } | |
| 1357 | 1391 | ||
| 1392 | + | // updateRowFavoriteIndicator updates a single row's favorite indicator CSS class. | |
| 1393 | + | // Must be called from GTK main thread (via glib.IdleAdd). | |
| 1394 | + | func (o *Omnibox) updateRowFavoriteIndicator(index int, isFavorite bool) { | |
| 1395 | + | row := o.listBox.GetRowAtIndex(index) | |
| 1396 | + | if row == nil { | |
| 1397 | + | return | |
| 1398 | + | } | |
| 1399 | + | if isFavorite { | |
| 1400 | + | row.AddCssClass("omnibox-row-favorite") | |
| 1401 | + | } else { | |
| 1402 | + | row.RemoveCssClass("omnibox-row-favorite") | |
| 1403 | + | } | |
| 1404 | + | } | |
| 1405 | + | ||
| 1358 | 1406 | // yankSelectedURL copies the URL of the selected item to clipboard. | |
| 1359 | 1407 | func (o *Omnibox) yankSelectedURL() { | |
| 1360 | 1408 | log := logging.FromContext(o.ctx) | |
| @@ -1400,7 +1448,7 @@ func (o *Omnibox) yankSelectedURL() { | |||
| 1400 | 1448 | // Show toast notification on success (must run on GTK main thread) | |
| 1401 | 1449 | if o.onToast != nil { | |
| 1402 | 1450 | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 1403 | - | o.onToast("URL copied") | |
| 1451 | + | o.onToast(ctx, "URL copied", ToastSuccess) | |
| 1404 | 1452 | return false // Don't repeat | |
| 1405 | 1453 | }) | |
| 1406 | 1454 | glib.IdleAdd(&cb, 0) | |
| @@ -98,7 +98,7 @@ type Omnibox struct { | |||||
| 98 | // Callbacks | 98 | // Callbacks | ||
| 99 | onNavigate func(url string) | 99 | onNavigate func(url string) | ||
| 100 | onClose func() | 100 | onClose func() | ||
| 101 | - | onToast func(message string) | 101 | + | onToast func(ctx context.Context, message string, level ToastLevel) |
| 102 | 102 | ||||
| 103 | // Debouncing | 103 | // Debouncing | ||
| 104 | debounceTimer *time.Timer | 104 | debounceTimer *time.Timer | ||
| @@ -129,9 +129,9 @@ type OmniboxConfig struct { | |||||
| 129 | ShortcutsUC *usecase.SearchShortcutsUseCase | 129 | ShortcutsUC *usecase.SearchShortcutsUseCase | ||
| 130 | DefaultSearch string | 130 | DefaultSearch string | ||
| 131 | InitialBehavior string | 131 | InitialBehavior string | ||
| 132 | - | UIScale float64 // UI scale for favicon sizing | 132 | + | UIScale float64 // UI scale for favicon sizing |
| 133 | - | OnNavigate func(url string) // Callback when user navigates via omnibox | 133 | + | OnNavigate func(url string) // Callback when user navigates via omnibox |
| 134 | - | OnToast func(message string) // Callback to show toast notification | 134 | + | OnToast func(ctx context.Context, message string, level ToastLevel) // Callback to show toast notification |
| 135 | } | 135 | } | ||
| 136 | 136 | ||||
| 137 | // NewOmnibox creates a new native GTK4 omnibox widget. | 137 | // NewOmnibox creates a new native GTK4 omnibox widget. | ||
| @@ -1291,15 +1291,15 @@ func (o *Omnibox) toggleFavorite() { | |||||
| 1291 | } | 1291 | } | ||
| 1292 | 1292 | ||||
| 1293 | if mode == ViewModeHistory { | 1293 | if mode == ViewModeHistory { | ||
| 1294 | - | // Add to favorites | 1294 | + | // Toggle favorite in history mode |
| 1295 | if idx < 0 || idx >= len(suggestions) { | 1295 | if idx < 0 || idx >= len(suggestions) { | ||
| 1296 | - | log.Debug().Int("index", idx).Msg("add favorite: invalid selection") | 1296 | + | log.Debug().Int("index", idx).Msg("toggle favorite: invalid selection") |
| 1297 | return | 1297 | return | ||
| 1298 | } | 1298 | } | ||
| 1299 | 1299 | ||||
| 1300 | s := suggestions[idx] | 1300 | s := suggestions[idx] | ||
| 1301 | if s.URL == "" { | 1301 | if s.URL == "" { | ||
| 1302 | - | log.Debug().Msg("add favorite: empty URL") | 1302 | + | log.Debug().Msg("toggle favorite: empty URL") |
| 1303 | return | 1303 | return | ||
| 1304 | } | 1304 | } | ||
| 1305 | 1305 | ||||
| @@ -1307,20 +1307,39 @@ func (o *Omnibox) toggleFavorite() { | |||||
| 1307 | ctx := o.ctx | 1307 | ctx := o.ctx | ||
| 1308 | goLog := logging.FromContext(ctx) | 1308 | goLog := logging.FromContext(ctx) | ||
| 1309 | 1309 | ||||
| 1310 | - | goLog.Debug().Str("url", s.URL).Str("title", s.Title).Msg("adding to favorites") | 1310 | + | result, err := o.favoritesUC.Toggle(ctx, s.URL, s.Title) |
| 1311 | - | ||||
| 1312 | - | addInput := usecase.AddFavoriteInput{ | |||
| 1313 | - | URL: s.URL, | |||
| 1314 | - | Title: s.Title, | |||
| 1315 | - | } | |||
| 1316 | - | ||||
| 1317 | - | fav, err := o.favoritesUC.Add(ctx, addInput) | |||
| 1318 | if err != nil { | 1311 | if err != nil { | ||
| 1319 | - | goLog.Error().Err(err).Str("url", s.URL).Msg("failed to add favorite") | 1312 | + | goLog.Error().Err(err).Str("url", s.URL).Msg("failed to toggle favorite") |
| 1313 | + | if o.onToast != nil { | |||
| 1314 | + | msg := "Failed to add favorite" | |||
| 1315 | + | if s.IsFavorite { | |||
| 1316 | + | msg = "Failed to remove favorite" | |||
| 1317 | + | } | |||
| 1318 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |||
| 1319 | + | o.onToast(ctx, msg, ToastError) | |||
| 1320 | + | return false | |||
| 1321 | + | }) | |||
| 1322 | + | glib.IdleAdd(&cb, 0) | |||
| 1323 | + | } | |||
| 1320 | return | 1324 | return | ||
| 1321 | } | 1325 | } | ||
| 1322 | 1326 | ||||
| 1323 | - | goLog.Info().Str("url", s.URL).Int64("id", int64(fav.ID)).Msg("favorite added from omnibox") | 1327 | + | // Update suggestion state |
| 1328 | + | o.mu.Lock() | |||
| 1329 | + | if idx < len(o.suggestions) && o.suggestions[idx].URL == s.URL { | |||
| 1330 | + | o.suggestions[idx].IsFavorite = result.Added | |||
| 1331 | + | } | |||
| 1332 | + | o.mu.Unlock() | |||
| 1333 | + | ||||
| 1334 | + | // Update row CSS and show toast on GTK main thread | |||
| 1335 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |||
| 1336 | + | o.updateRowFavoriteIndicator(idx, result.Added) | |||
| 1337 | + | if o.onToast != nil { | |||
| 1338 | + | o.onToast(ctx, result.Message, ToastSuccess) | |||
| 1339 | + | } | |||
| 1340 | + | return false | |||
| 1341 | + | }) | |||
| 1342 | + | glib.IdleAdd(&cb, 0) | |||
| 1324 | }() | 1343 | }() | ||
| 1325 | } else { | 1344 | } else { | ||
| 1326 | // Remove from favorites | 1345 | // Remove from favorites | ||
| @@ -1344,17 +1363,46 @@ func (o *Omnibox) toggleFavorite() { | |||||
| 1344 | err := o.favoritesUC.Remove(ctx, entity.FavoriteID(f.ID)) | 1363 | err := o.favoritesUC.Remove(ctx, entity.FavoriteID(f.ID)) | ||
| 1345 | if err != nil { | 1364 | if err != nil { | ||
| 1346 | log.Error().Err(err).Int64("id", f.ID).Msg("failed to remove favorite") | 1365 | log.Error().Err(err).Int64("id", f.ID).Msg("failed to remove favorite") | ||
| 1366 | + | // Show error toast | |||
| 1367 | + | if o.onToast != nil { | |||
| 1368 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |||
| 1369 | + | o.onToast(ctx, "Failed to remove favorite", ToastError) | |||
| 1370 | + | return false | |||
| 1371 | + | }) | |||
| 1372 | + | glib.IdleAdd(&cb, 0) | |||
| 1373 | + | } | |||
| 1347 | return | 1374 | return | ||
| 1348 | } | 1375 | } | ||
| 1349 | 1376 | ||||
| 1350 | log.Info().Int64("id", f.ID).Str("url", f.URL).Msg("favorite removed from omnibox") | 1377 | log.Info().Int64("id", f.ID).Str("url", f.URL).Msg("favorite removed from omnibox") | ||
| 1351 | 1378 | ||||
| 1352 | - | // Refresh favorites list | 1379 | + | // Refresh favorites list and show toast |
| 1353 | o.loadFavorites(o.entry.GetText()) | 1380 | o.loadFavorites(o.entry.GetText()) | ||
| 1381 | + | if o.onToast != nil { | |||
| 1382 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |||
| 1383 | + | o.onToast(ctx, "Favorite removed", ToastSuccess) | |||
| 1384 | + | return false | |||
| 1385 | + | }) | |||
| 1386 | + | glib.IdleAdd(&cb, 0) | |||
| 1387 | + | } | |||
| 1354 | }() | 1388 | }() | ||
| 1355 | } | 1389 | } | ||
| 1356 | } | 1390 | } | ||
| 1357 | 1391 | ||||
| 1392 | + | // updateRowFavoriteIndicator updates a single row's favorite indicator CSS class. | |||
| 1393 | + | // Must be called from GTK main thread (via glib.IdleAdd). | |||
| 1394 | + | func (o *Omnibox) updateRowFavoriteIndicator(index int, isFavorite bool) { | |||
| 1395 | + | row := o.listBox.GetRowAtIndex(index) | |||
| 1396 | + | if row == nil { | |||
| 1397 | + | return | |||
| 1398 | + | } | |||
| 1399 | + | if isFavorite { | |||
| 1400 | + | row.AddCssClass("omnibox-row-favorite") | |||
| 1401 | + | } else { | |||
| 1402 | + | row.RemoveCssClass("omnibox-row-favorite") | |||
| 1403 | + | } | |||
| 1404 | + | } | |||
| 1405 | + | ||||
| 1358 | // yankSelectedURL copies the URL of the selected item to clipboard. | 1406 | // yankSelectedURL copies the URL of the selected item to clipboard. | ||
| 1359 | func (o *Omnibox) yankSelectedURL() { | 1407 | func (o *Omnibox) yankSelectedURL() { | ||
| 1360 | log := logging.FromContext(o.ctx) | 1408 | log := logging.FromContext(o.ctx) | ||
| @@ -1400,7 +1448,7 @@ func (o *Omnibox) yankSelectedURL() { | |||||
| 1400 | // Show toast notification on success (must run on GTK main thread) | 1448 | // Show toast notification on success (must run on GTK main thread) | ||
| 1401 | if o.onToast != nil { | 1449 | if o.onToast != nil { | ||
| 1402 | cb := glib.SourceFunc(func(_ uintptr) bool { | 1450 | cb := glib.SourceFunc(func(_ uintptr) bool { | ||
| 1403 | - | o.onToast("URL copied") | 1451 | + | o.onToast(ctx, "URL copied", ToastSuccess) |
| 1404 | return false // Don't repeat | 1452 | return false // Don't repeat | ||
| 1405 | }) | 1453 | }) | ||
| 1406 | glib.IdleAdd(&cb, 0) | 1454 | glib.IdleAdd(&cb, 0) | ||
M internal/ui/component/workspace_view.go +2 -2
| @@ -556,8 +556,8 @@ func (wv *WorkspaceView) ShowOmnibox(ctx context.Context, query string) { | |||
| 556 | 556 | ||
| 557 | 557 | // Create omnibox with pane-specific toast callback | |
| 558 | 558 | cfg := wv.omniboxCfg | |
| 559 | - | cfg.OnToast = func(message string) { | |
| 560 | - | pv.ShowToast(ctx, message, ToastSuccess) | |
| 559 | + | cfg.OnToast = func(toastCtx context.Context, message string, level ToastLevel) { | |
| 560 | + | pv.ShowToast(toastCtx, message, level) | |
| 561 | 561 | } | |
| 562 | 562 | omnibox := NewOmnibox(ctx, cfg) | |
| 563 | 563 | if omnibox == nil { | |
| @@ -556,8 +556,8 @@ func (wv *WorkspaceView) ShowOmnibox(ctx context.Context, query string) { | |||||
| 556 | 556 | ||||
| 557 | // Create omnibox with pane-specific toast callback | 557 | // Create omnibox with pane-specific toast callback | ||
| 558 | cfg := wv.omniboxCfg | 558 | cfg := wv.omniboxCfg | ||
| 559 | - | cfg.OnToast = func(message string) { | 559 | + | cfg.OnToast = func(toastCtx context.Context, message string, level ToastLevel) { |
| 560 | - | pv.ShowToast(ctx, message, ToastSuccess) | 560 | + | pv.ShowToast(toastCtx, message, level) |
| 561 | } | 561 | } | ||
| 562 | omnibox := NewOmnibox(ctx, cfg) | 562 | omnibox := NewOmnibox(ctx, cfg) | ||
| 563 | if omnibox == nil { | 563 | if omnibox == nil { | ||