detached 2ddb1b2

fix(favorites): address review follow-ups

commit
2ddb1b29ccdfb802cf5cf20e4372867103e9cc89
author
brice · · 2026-06-28 13:09 UTC
parent
7be5016
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +100 -17
M docs/systemviews-wasm-crud-plan.md +3 -3
@@ -52,9 +52,9 @@ Preferred direction for new work:
5252 | Operation | Old Svelte surface | Backend message/API | Current WASM status | Gap |
5353 | --- | --- | --- | --- | --- |
5454 | Read favorites | `FavoritesPanel.svelte`, `FavoriteGrid.svelte`, `FavoriteCard.svelte` | `favorite_list` | Present, but bare link list (`favorites_view.go`) with tags-first data. | Add rich rows/cards, favicons, shortcut badges, and tag metadata. |
55-| Create favorite | Old homepage hints imply adding/pinning from history; native omnibox toggle exists outside this page. | `ManageFavoritesUseCase.Add` exists, but no homepage/systemview message. | Missing from `port.HomepageFavorites`/`SystemviewFavoritesService` and handlers. | Add `favorite_create` handler/bridge and UI. |
56-| Update favorite | `FavoriteEditor.svelte` locally updates title, shortcut, and tags. | `favorite_set_shortcut`, `tag_assign`, `tag_remove`; no title update handler. | Partial bridge support. | Add `favorite_update` for title/favicon/url policy; keep shortcut/tag specialized calls or fold into update after design. |
57-| Delete favorite | `FavoriteEditor.svelte` local-only delete placeholder. | `ManageFavoritesUseCase.Remove` exists, but no homepage/systemview message. | Missing. | Add `favorite_delete` handler/bridge/UI. |
55+| Create favorite | Old homepage hints imply adding/pinning from history; native omnibox toggle exists outside this page. | `ManageFavoritesUseCase.AddFavorite`, `port.HomepageFavorites`, `SystemviewFavoritesService`, homepage handlers, and bridge expose `favorite_create`. | Supported in WASM UI. | Keep validation/error states covered. |
56+| Update favorite | `FavoriteEditor.svelte` locally updates title, shortcut, and tags. | `ManageFavoritesUseCase.UpdateFavorite`, `favorite_update`, `favorite_set_shortcut`, `tag_assign`, and `tag_remove` are exposed through the ports, handlers, and bridge. | Supported in WASM UI. | Keep shortcut/tag semantics covered by regression tests. |
57+| Delete favorite | `FavoriteEditor.svelte` local-only delete placeholder. | `ManageFavoritesUseCase.DeleteFavorite`/`Remove`, `port.HomepageFavorites`, `SystemviewFavoritesService`, homepage handlers, and bridge expose `favorite_delete`. | Supported in WASM UI. | Keep destructive confirmation and refresh behavior covered. |
5858 | Read tags | `TagCloud.svelte` | `tag_list` | Present as static list. | Add color chips and selection. |
5959 | Create tag | `TagCloud.svelte` inline create | `tag_create` | Bridge and handler exist. | Add UI with color picker/palette. |
6060 | Update tag | Handler exists (`tag_update`). | `tag_update` | Bridge exists. | Add rename/color edit UI. |
@@ -52,9 +52,9 @@ Preferred direction for new work:
52 | Operation | Old Svelte surface | Backend message/API | Current WASM status | Gap |52 | Operation | Old Svelte surface | Backend message/API | Current WASM status | Gap |
53 | --- | --- | --- | --- | --- |53 | --- | --- | --- | --- | --- |
54 | Read favorites | `FavoritesPanel.svelte`, `FavoriteGrid.svelte`, `FavoriteCard.svelte` | `favorite_list` | Present, but bare link list (`favorites_view.go`) with tags-first data. | Add rich rows/cards, favicons, shortcut badges, and tag metadata. |54 | Read favorites | `FavoritesPanel.svelte`, `FavoriteGrid.svelte`, `FavoriteCard.svelte` | `favorite_list` | Present, but bare link list (`favorites_view.go`) with tags-first data. | Add rich rows/cards, favicons, shortcut badges, and tag metadata. |
55-| Create favorite | Old homepage hints imply adding/pinning from history; native omnibox toggle exists outside this page. | `ManageFavoritesUseCase.Add` exists, but no homepage/systemview message. | Missing from `port.HomepageFavorites`/`SystemviewFavoritesService` and handlers. | Add `favorite_create` handler/bridge and UI. |55+| Create favorite | Old homepage hints imply adding/pinning from history; native omnibox toggle exists outside this page. | `ManageFavoritesUseCase.AddFavorite`, `port.HomepageFavorites`, `SystemviewFavoritesService`, homepage handlers, and bridge expose `favorite_create`. | Supported in WASM UI. | Keep validation/error states covered. |
56-| Update favorite | `FavoriteEditor.svelte` locally updates title, shortcut, and tags. | `favorite_set_shortcut`, `tag_assign`, `tag_remove`; no title update handler. | Partial bridge support. | Add `favorite_update` for title/favicon/url policy; keep shortcut/tag specialized calls or fold into update after design. |56+| Update favorite | `FavoriteEditor.svelte` locally updates title, shortcut, and tags. | `ManageFavoritesUseCase.UpdateFavorite`, `favorite_update`, `favorite_set_shortcut`, `tag_assign`, and `tag_remove` are exposed through the ports, handlers, and bridge. | Supported in WASM UI. | Keep shortcut/tag semantics covered by regression tests. |
57-| Delete favorite | `FavoriteEditor.svelte` local-only delete placeholder. | `ManageFavoritesUseCase.Remove` exists, but no homepage/systemview message. | Missing. | Add `favorite_delete` handler/bridge/UI. |57+| Delete favorite | `FavoriteEditor.svelte` local-only delete placeholder. | `ManageFavoritesUseCase.DeleteFavorite`/`Remove`, `port.HomepageFavorites`, `SystemviewFavoritesService`, homepage handlers, and bridge expose `favorite_delete`. | Supported in WASM UI. | Keep destructive confirmation and refresh behavior covered. |
58 | Read tags | `TagCloud.svelte` | `tag_list` | Present as static list. | Add color chips and selection. |58 | Read tags | `TagCloud.svelte` | `tag_list` | Present as static list. | Add color chips and selection. |
59 | Create tag | `TagCloud.svelte` inline create | `tag_create` | Bridge and handler exist. | Add UI with color picker/palette. |59 | Create tag | `TagCloud.svelte` inline create | `tag_create` | Bridge and handler exist. | Add UI with color picker/palette. |
60 | Update tag | Handler exists (`tag_update`). | `tag_update` | Bridge exists. | Add rename/color edit UI. |60 | Update tag | Handler exists (`tag_update`). | `tag_update` | Bridge exists. | Add rename/color edit UI. |
M internal/application/usecase/manage_favorites.go +1 -5
@@ -82,11 +82,7 @@ func (uc *ManageFavoritesUseCase) addNewFavorite(ctx context.Context, input AddF
8282 // Assign tags
8383 for _, tagID := range input.Tags {
8484 if err := uc.tagRepo.AssignToFavorite(ctx, tagID, fav.ID); err != nil {
85- log.Warn().
86- Int64("tag_id", int64(tagID)).
87- Int64("favorite_id", int64(fav.ID)).
88- Err(err).
89- Msg("failed to assign tag")
85+ return nil, fmt.Errorf("failed to assign tag %d to favorite %d: %w", tagID, fav.ID, err)
9086 }
9187 }
9288
@@ -82,11 +82,7 @@ func (uc *ManageFavoritesUseCase) addNewFavorite(ctx context.Context, input AddF
82 // Assign tags82 // Assign tags
83 for _, tagID := range input.Tags {83 for _, tagID := range input.Tags {
84 if err := uc.tagRepo.AssignToFavorite(ctx, tagID, fav.ID); err != nil {84 if err := uc.tagRepo.AssignToFavorite(ctx, tagID, fav.ID); err != nil {
85- log.Warn().85+ return nil, fmt.Errorf("failed to assign tag %d to favorite %d: %w", tagID, fav.ID, err)
86- Int64("tag_id", int64(tagID)).
87- Int64("favorite_id", int64(fav.ID)).
88- Err(err).
89- Msg("failed to assign tag")
90 }86 }
91 }87 }
92 88
M internal/application/usecase/manage_favorites_test.go +24 -0
@@ -429,6 +429,30 @@ func TestManageFavoritesUseCase_AddFavoriteAssignsTagsWithoutFolder(t *testing.T
429429 assert.Equal(t, entity.FavoriteID(42), fav.ID)
430430 }
431431
432+func TestManageFavoritesUseCase_AddFavoriteReturnsAssignTagError(t *testing.T) {
433+ ctx := testContext()
434+
435+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
436+ tagRepo := repomocks.NewMockTagRepository(t)
437+
438+ favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil).Once()
439+ favoriteRepo.EXPECT().Save(mock.Anything, mock.Anything).Run(func(_ context.Context, fav *entity.Favorite) {
440+ fav.ID = entity.FavoriteID(42)
441+ }).Return(nil).Once()
442+ tagRepo.EXPECT().AssignToFavorite(mock.Anything, entity.TagID(7), entity.FavoriteID(42)).Return(errors.New("tag missing")).Once()
443+
444+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
445+
446+ fav, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{
447+ URL: "https://example.com",
448+ Title: "Example",
449+ Tags: []entity.TagID{7},
450+ })
451+ require.Error(t, err)
452+ assert.Nil(t, fav)
453+ assert.Contains(t, err.Error(), "failed to assign tag 7 to favorite 42")
454+}
455+
432456 func TestManageFavoritesUseCase_AddFavoriteRejectsUnsafeOrMalformedURL(t *testing.T) {
433457 ctx := testContext()
434458
@@ -429,6 +429,30 @@ func TestManageFavoritesUseCase_AddFavoriteAssignsTagsWithoutFolder(t *testing.T
429 assert.Equal(t, entity.FavoriteID(42), fav.ID)429 assert.Equal(t, entity.FavoriteID(42), fav.ID)
430 }430 }
431 431
432+func TestManageFavoritesUseCase_AddFavoriteReturnsAssignTagError(t *testing.T) {
433+ ctx := testContext()
434+
435+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
436+ tagRepo := repomocks.NewMockTagRepository(t)
437+
438+ favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil).Once()
439+ favoriteRepo.EXPECT().Save(mock.Anything, mock.Anything).Run(func(_ context.Context, fav *entity.Favorite) {
440+ fav.ID = entity.FavoriteID(42)
441+ }).Return(nil).Once()
442+ tagRepo.EXPECT().AssignToFavorite(mock.Anything, entity.TagID(7), entity.FavoriteID(42)).Return(errors.New("tag missing")).Once()
443+
444+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
445+
446+ fav, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{
447+ URL: "https://example.com",
448+ Title: "Example",
449+ Tags: []entity.TagID{7},
450+ })
451+ require.Error(t, err)
452+ assert.Nil(t, fav)
453+ assert.Contains(t, err.Error(), "failed to assign tag 7 to favorite 42")
454+}
455+
432 func TestManageFavoritesUseCase_AddFavoriteRejectsUnsafeOrMalformedURL(t *testing.T) {456 func TestManageFavoritesUseCase_AddFavoriteRejectsUnsafeOrMalformedURL(t *testing.T) {
433 ctx := testContext()457 ctx := testContext()
434 458
M internal/infrastructure/handlers/homepage/favorites_test.go +22 -0
@@ -93,3 +93,25 @@ func TestHandleFavoriteUpdatePreservesShortcutWhenOmitted(t *testing.T) {
9393 require.True(t, ok)
9494 require.True(t, resp.Success)
9595 }
96+func TestHandleFavoriteUpdateClearsShortcutWhenExplicitNull(t *testing.T) {
97+ t.Parallel()
98+
99+ favorites := portmocks.NewMockHomepageFavorites(t)
100+ favorites.EXPECT().
101+ UpdateFavorite(mock.Anything, dto.FavoriteUpdateInput{
102+ ID: entity.FavoriteID(42),
103+ Title: "Updated",
104+ FaviconURL: "https://example.com/favicon.ico",
105+ ShortcutKeySet: true,
106+ }).
107+ Return(&entity.Favorite{ID: 42, URL: "https://example.com", Title: "Updated"}, nil).
108+ Once()
109+
110+ handler := NewFavoritesHandlers(favorites).HandleUpdate()
111+ got, err := handler.Handle(context.Background(), port.WebViewID(0), json.RawMessage(`{"requestId":"req-5","id":42,"title":"Updated","favicon_url":"https://example.com/favicon.ico","shortcut_key":null}`))
112+ require.NoError(t, err)
113+
114+ resp, ok := got.(Response)
115+ require.True(t, ok)
116+ require.True(t, resp.Success)
117+}
@@ -93,3 +93,25 @@ func TestHandleFavoriteUpdatePreservesShortcutWhenOmitted(t *testing.T) {
93 require.True(t, ok)93 require.True(t, ok)
94 require.True(t, resp.Success)94 require.True(t, resp.Success)
95 }95 }
96+func TestHandleFavoriteUpdateClearsShortcutWhenExplicitNull(t *testing.T) {
97+ t.Parallel()
98+
99+ favorites := portmocks.NewMockHomepageFavorites(t)
100+ favorites.EXPECT().
101+ UpdateFavorite(mock.Anything, dto.FavoriteUpdateInput{
102+ ID: entity.FavoriteID(42),
103+ Title: "Updated",
104+ FaviconURL: "https://example.com/favicon.ico",
105+ ShortcutKeySet: true,
106+ }).
107+ Return(&entity.Favorite{ID: 42, URL: "https://example.com", Title: "Updated"}, nil).
108+ Once()
109+
110+ handler := NewFavoritesHandlers(favorites).HandleUpdate()
111+ got, err := handler.Handle(context.Background(), port.WebViewID(0), json.RawMessage(`{"requestId":"req-5","id":42,"title":"Updated","favicon_url":"https://example.com/favicon.ico","shortcut_key":null}`))
112+ require.NoError(t, err)
113+
114+ resp, ok := got.(Response)
115+ require.True(t, ok)
116+ require.True(t, resp.Success)
117+}
M internal/infrastructure/persistence/sqlite/favorite_repo.go +17 -9
@@ -13,6 +13,8 @@ import (
1313
1414 // ==================== Favorite Repository ====================
1515
16+const favoriteTagHydrationBatchSize = 900
17+
1618 type favoriteRepo struct {
1719 queries *sqlc.Queries
1820 }
@@ -146,16 +148,22 @@ func (r *favoriteRepo) hydrateTags(ctx context.Context, favorites []*entity.Favo
146148 return nil
147149 }
148150
149- rows, err := r.queries.GetTagsForFavorites(ctx, ids)
150- if err != nil {
151- return err
152- }
153- for _, row := range rows {
154- fav := byID[entity.FavoriteID(row.FavoriteID)]
155- if fav == nil {
156- continue
151+ for start := 0; start < len(ids); start += favoriteTagHydrationBatchSize {
152+ end := start + favoriteTagHydrationBatchSize
153+ if end > len(ids) {
154+ end = len(ids)
155+ }
156+ rows, err := r.queries.GetTagsForFavorites(ctx, ids[start:end])
157+ if err != nil {
158+ return err
159+ }
160+ for _, row := range rows {
161+ fav := byID[entity.FavoriteID(row.FavoriteID)]
162+ if fav == nil {
163+ continue
164+ }
165+ fav.Tags = append(fav.Tags, *tagFromRow(row.FavoriteTag))
157166 }
158- fav.Tags = append(fav.Tags, *tagFromRow(row.FavoriteTag))
159167 }
160168 return nil
161169 }
@@ -13,6 +13,8 @@ import (
13 13
14 // ==================== Favorite Repository ====================14 // ==================== Favorite Repository ====================
15 15
16+const favoriteTagHydrationBatchSize = 900
17+
16 type favoriteRepo struct {18 type favoriteRepo struct {
17 queries *sqlc.Queries19 queries *sqlc.Queries
18 }20 }
@@ -146,16 +148,22 @@ func (r *favoriteRepo) hydrateTags(ctx context.Context, favorites []*entity.Favo
146 return nil148 return nil
147 }149 }
148 150
149- rows, err := r.queries.GetTagsForFavorites(ctx, ids)151+ for start := 0; start < len(ids); start += favoriteTagHydrationBatchSize {
150- if err != nil {152+ end := start + favoriteTagHydrationBatchSize
151- return err153+ if end > len(ids) {
152- }154+ end = len(ids)
153- for _, row := range rows {155+ }
154- fav := byID[entity.FavoriteID(row.FavoriteID)]156+ rows, err := r.queries.GetTagsForFavorites(ctx, ids[start:end])
155- if fav == nil {157+ if err != nil {
156- continue158+ return err
159+ }
160+ for _, row := range rows {
161+ fav := byID[entity.FavoriteID(row.FavoriteID)]
162+ if fav == nil {
163+ continue
164+ }
165+ fav.Tags = append(fav.Tags, *tagFromRow(row.FavoriteTag))
157 }166 }
158- fav.Tags = append(fav.Tags, *tagFromRow(row.FavoriteTag))
159 }167 }
160 return nil168 return nil
161 }169 }
M internal/infrastructure/persistence/sqlite/favorite_repo_test.go +33 -0
@@ -3,6 +3,7 @@ package sqlite
33 import (
44 "context"
55 "database/sql"
6+ "fmt"
67 "testing"
78
89 "github.com/bnema/dumber/internal/domain/entity"
@@ -154,6 +155,38 @@ func TestFavoriteRepositorySaveUpdatesShortcutKey(t *testing.T) {
154155 }
155156 }
156157
158+func TestFavoriteRepositoryGetAllHydratesTagsInBatches(t *testing.T) {
159+ db := openFavoriteRepoTestDB(t)
160+ mustExec(t, db, `INSERT INTO favorite_tags(id, name, color) VALUES (1, 'batched', '#111111')`)
161+ for i := int64(1); i <= favoriteTagHydrationBatchSize+5; i++ {
162+ _, err := db.Exec(
163+ `INSERT INTO favorites(id, url, title, position) VALUES (?, ?, ?, ?)`,
164+ i,
165+ fmt.Sprintf("https://batch.example/%d", i),
166+ "Batch",
167+ i,
168+ )
169+ if err != nil {
170+ t.Fatalf("insert favorite %d: %v", i, err)
171+ }
172+ _, err = db.Exec(`INSERT INTO favorite_tag_assignments(favorite_id, tag_id) VALUES (?, 1)`, i)
173+ if err != nil {
174+ t.Fatalf("insert assignment %d: %v", i, err)
175+ }
176+ }
177+ repo := NewFavoriteRepository(db)
178+
179+ favorites, err := repo.GetAll(context.Background())
180+ if err != nil {
181+ t.Fatalf("GetAll: %v", err)
182+ }
183+ if len(favorites) != favoriteTagHydrationBatchSize+5 {
184+ t.Fatalf("expected %d favorites, got %d", favoriteTagHydrationBatchSize+5, len(favorites))
185+ }
186+ assertTagNames(t, favorites[0], "batched")
187+ assertTagNames(t, favorites[len(favorites)-1], "batched")
188+}
189+
157190 func assertTagNames(t *testing.T, fav *entity.Favorite, names ...string) {
158191 t.Helper()
159192 if fav == nil {
@@ -3,6 +3,7 @@ package sqlite
3 import (3 import (
4 "context"4 "context"
5 "database/sql"5 "database/sql"
6+ "fmt"
6 "testing"7 "testing"
7 8
8 "github.com/bnema/dumber/internal/domain/entity"9 "github.com/bnema/dumber/internal/domain/entity"
@@ -154,6 +155,38 @@ func TestFavoriteRepositorySaveUpdatesShortcutKey(t *testing.T) {
154 }155 }
155 }156 }
156 157
158+func TestFavoriteRepositoryGetAllHydratesTagsInBatches(t *testing.T) {
159+ db := openFavoriteRepoTestDB(t)
160+ mustExec(t, db, `INSERT INTO favorite_tags(id, name, color) VALUES (1, 'batched', '#111111')`)
161+ for i := int64(1); i <= favoriteTagHydrationBatchSize+5; i++ {
162+ _, err := db.Exec(
163+ `INSERT INTO favorites(id, url, title, position) VALUES (?, ?, ?, ?)`,
164+ i,
165+ fmt.Sprintf("https://batch.example/%d", i),
166+ "Batch",
167+ i,
168+ )
169+ if err != nil {
170+ t.Fatalf("insert favorite %d: %v", i, err)
171+ }
172+ _, err = db.Exec(`INSERT INTO favorite_tag_assignments(favorite_id, tag_id) VALUES (?, 1)`, i)
173+ if err != nil {
174+ t.Fatalf("insert assignment %d: %v", i, err)
175+ }
176+ }
177+ repo := NewFavoriteRepository(db)
178+
179+ favorites, err := repo.GetAll(context.Background())
180+ if err != nil {
181+ t.Fatalf("GetAll: %v", err)
182+ }
183+ if len(favorites) != favoriteTagHydrationBatchSize+5 {
184+ t.Fatalf("expected %d favorites, got %d", favoriteTagHydrationBatchSize+5, len(favorites))
185+ }
186+ assertTagNames(t, favorites[0], "batched")
187+ assertTagNames(t, favorites[len(favorites)-1], "batched")
188+}
189+
157 func assertTagNames(t *testing.T, fav *entity.Favorite, names ...string) {190 func assertTagNames(t *testing.T, fav *entity.Favorite, names ...string) {
158 t.Helper()191 t.Helper()
159 if fav == nil {192 if fav == 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