detached ff5b26e

fix(omnibox): add reactive favorite toggle with toast notifications

commit
ff5b26e09ea1d67233f3c46afd47c802c8c0c92f
author
bnema · · 2025-12-28 17:49 UTC
parent
2391e76
signature
GPG Unverified2B82207D370127A0 The 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.
4040 - **Console logging**: Standardized console timestamps to `HH:MM:SS` across bootstrap and session logging.
4141
4242 ### 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.
4344 - **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.
4445 - **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.
4546 - **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 ### Fixed42 ### 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
196196 return fav != nil, nil
197197 }
198198
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+
199251 // GetAllURLs returns a set of all favorited URLs for efficient batch lookup.
200252 func (uc *ManageFavoritesUseCase) GetAllURLs(ctx context.Context) (map[string]struct{}, error) {
201253 log := logging.FromContext(ctx)
@@ -196,6 +196,58 @@ func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, url string) (b
196 return fav != nil, nil196 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.
7575 assert.Nil(t, urls)
7676 assert.Contains(t, err.Error(), "failed to get favorites")
7777 }
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 {
9898 // Callbacks
9999 onNavigate func(url string)
100100 onClose func()
101- onToast func(message string)
101+ onToast func(ctx context.Context, message string, level ToastLevel)
102102
103103 // Debouncing
104104 debounceTimer *time.Timer
@@ -129,9 +129,9 @@ type OmniboxConfig struct {
129129 ShortcutsUC *usecase.SearchShortcutsUseCase
130130 DefaultSearch string
131131 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
135135 }
136136
137137 // NewOmnibox creates a new native GTK4 omnibox widget.
@@ -1291,15 +1291,15 @@ func (o *Omnibox) toggleFavorite() {
12911291 }
12921292
12931293 if mode == ViewModeHistory {
1294- // Add to favorites
1294+ // Toggle favorite in history mode
12951295 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")
12971297 return
12981298 }
12991299
13001300 s := suggestions[idx]
13011301 if s.URL == "" {
1302- log.Debug().Msg("add favorite: empty URL")
1302+ log.Debug().Msg("toggle favorite: empty URL")
13031303 return
13041304 }
13051305
@@ -1307,20 +1307,39 @@ func (o *Omnibox) toggleFavorite() {
13071307 ctx := o.ctx
13081308 goLog := logging.FromContext(ctx)
13091309
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)
13181311 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+ }
13201324 return
13211325 }
13221326
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)
13241343 }()
13251344 } else {
13261345 // Remove from favorites
@@ -1344,17 +1363,46 @@ func (o *Omnibox) toggleFavorite() {
13441363 err := o.favoritesUC.Remove(ctx, entity.FavoriteID(f.ID))
13451364 if err != nil {
13461365 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+ }
13471374 return
13481375 }
13491376
13501377 log.Info().Int64("id", f.ID).Str("url", f.URL).Msg("favorite removed from omnibox")
13511378
1352- // Refresh favorites list
1379+ // Refresh favorites list and show toast
13531380 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+ }
13541388 }()
13551389 }
13561390 }
13571391
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+
13581406 // yankSelectedURL copies the URL of the selected item to clipboard.
13591407 func (o *Omnibox) yankSelectedURL() {
13601408 log := logging.FromContext(o.ctx)
@@ -1400,7 +1448,7 @@ func (o *Omnibox) yankSelectedURL() {
14001448 // Show toast notification on success (must run on GTK main thread)
14011449 if o.onToast != nil {
14021450 cb := glib.SourceFunc(func(_ uintptr) bool {
1403- o.onToast("URL copied")
1451+ o.onToast(ctx, "URL copied", ToastSuccess)
14041452 return false // Don't repeat
14051453 })
14061454 glib.IdleAdd(&cb, 0)
@@ -98,7 +98,7 @@ type Omnibox struct {
98 // Callbacks98 // 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 // Debouncing103 // Debouncing
104 debounceTimer *time.Timer104 debounceTimer *time.Timer
@@ -129,9 +129,9 @@ type OmniboxConfig struct {
129 ShortcutsUC *usecase.SearchShortcutsUseCase129 ShortcutsUC *usecase.SearchShortcutsUseCase
130 DefaultSearch string130 DefaultSearch string
131 InitialBehavior string131 InitialBehavior string
132- UIScale float64 // UI scale for favicon sizing132+ UIScale float64 // UI scale for favicon sizing
133- OnNavigate func(url string) // Callback when user navigates via omnibox133+ OnNavigate func(url string) // Callback when user navigates via omnibox
134- OnToast func(message string) // Callback to show toast notification134+ 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 favorites1294+ // 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 return1297 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 return1303 return
1304 }1304 }
1305 1305
@@ -1307,20 +1307,39 @@ func (o *Omnibox) toggleFavorite() {
1307 ctx := o.ctx1307 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 return1324 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 favorites1345 // 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 return1374 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 list1379+ // 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 repeat1452 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) {
556556
557557 // Create omnibox with pane-specific toast callback
558558 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)
561561 }
562562 omnibox := NewOmnibox(ctx, cfg)
563563 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 callback557 // Create omnibox with pane-specific toast callback
558 cfg := wv.omniboxCfg558 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 {

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