detached 1cb3bf3

fix(systemviews): address latest review feedback

commit
1cb3bf3978aacbb07d810d4cab31d858b5369f44
author
brice · · 2026-04-25 16:04 UTC
parent
ff220dd
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
23 files · +476 -165
M assets/systemviews/systemviews.css +2 -1
@@ -627,7 +627,8 @@ body {
627627 padding: 0;
628628 margin: -1px;
629629 overflow: hidden;
630- clip: rect(0, 0, 0, 0);
630+ -webkit-clip-path: inset(0 0 0 0);
631+ clip-path: inset(0 0 0 0);
631632 white-space: nowrap;
632633 border: 0;
633634 }
@@ -627,7 +627,8 @@ body {
627 padding: 0;627 padding: 0;
628 margin: -1px;628 margin: -1px;
629 overflow: hidden;629 overflow: hidden;
630- clip: rect(0, 0, 0, 0);630+ -webkit-clip-path: inset(0 0 0 0);
631+ clip-path: inset(0 0 0 0);
631 white-space: nowrap;632 white-space: nowrap;
632 border: 0;633 border: 0;
633 }634 }
M cmd/systemviews/bootstrap_test.go +2 -2
@@ -156,11 +156,11 @@ func (f *fakeBridgeService) List(context.Context) ([]*entity.Favorite, error) {
156156 return f.favorites, nil
157157 }
158158
159-func (*fakeBridgeService) CreateFavorite(context.Context, port.FavoriteCreateInput) (*entity.Favorite, error) {
159+func (*fakeBridgeService) CreateFavorite(context.Context, dto.FavoriteCreateInput) (*entity.Favorite, error) {
160160 return nil, nil
161161 }
162162
163-func (*fakeBridgeService) UpdateFavorite(context.Context, port.FavoriteUpdateInput) (*entity.Favorite, error) {
163+func (*fakeBridgeService) UpdateFavorite(context.Context, dto.FavoriteUpdateInput) (*entity.Favorite, error) {
164164 return nil, nil
165165 }
166166
@@ -156,11 +156,11 @@ func (f *fakeBridgeService) List(context.Context) ([]*entity.Favorite, error) {
156 return f.favorites, nil156 return f.favorites, nil
157 }157 }
158 158
159-func (*fakeBridgeService) CreateFavorite(context.Context, port.FavoriteCreateInput) (*entity.Favorite, error) {159+func (*fakeBridgeService) CreateFavorite(context.Context, dto.FavoriteCreateInput) (*entity.Favorite, error) {
160 return nil, nil160 return nil, nil
161 }161 }
162 162
163-func (*fakeBridgeService) UpdateFavorite(context.Context, port.FavoriteUpdateInput) (*entity.Favorite, error) {163+func (*fakeBridgeService) UpdateFavorite(context.Context, dto.FavoriteUpdateInput) (*entity.Favorite, error) {
164 return nil, nil164 return nil, nil
165 }165 }
166 166
A internal/application/dto/favorites.go +21 -0
@@ -0,0 +1,21 @@
1+package dto
2+
3+import "github.com/bnema/dumber/internal/domain/entity"
4+
5+// FavoriteCreateInput contains fields needed to create a favorite from a UI port.
6+type FavoriteCreateInput struct {
7+ URL string `json:"url"`
8+ Title string `json:"title"`
9+ FaviconURL string `json:"favicon_url"`
10+ FolderID *entity.FolderID `json:"folder_id"`
11+ Tags []entity.TagID `json:"tags"`
12+}
13+
14+// FavoriteUpdateInput contains editable favorite metadata.
15+type FavoriteUpdateInput struct {
16+ ID entity.FavoriteID `json:"id"`
17+ Title string `json:"title"`
18+ FaviconURL string `json:"favicon_url"`
19+ FolderID *entity.FolderID `json:"folder_id"`
20+ ShortcutKey *int `json:"shortcut_key"`
21+}
@@ -0,0 +1,21 @@
1+package dto
2+
3+import "github.com/bnema/dumber/internal/domain/entity"
4+
5+// FavoriteCreateInput contains fields needed to create a favorite from a UI port.
6+type FavoriteCreateInput struct {
7+ URL string `json:"url"`
8+ Title string `json:"title"`
9+ FaviconURL string `json:"favicon_url"`
10+ FolderID *entity.FolderID `json:"folder_id"`
11+ Tags []entity.TagID `json:"tags"`
12+}
13+
14+// FavoriteUpdateInput contains editable favorite metadata.
15+type FavoriteUpdateInput struct {
16+ ID entity.FavoriteID `json:"id"`
17+ Title string `json:"title"`
18+ FaviconURL string `json:"favicon_url"`
19+ FolderID *entity.FolderID `json:"folder_id"`
20+ ShortcutKey *int `json:"shortcut_key"`
21+}
M internal/application/port/homepage.go +3 -20
@@ -4,6 +4,7 @@ import (
44 "context"
55 "time"
66
7+ "github.com/bnema/dumber/internal/application/dto"
78 "github.com/bnema/dumber/internal/domain/entity"
89 )
910
@@ -34,30 +35,12 @@ type HomepageHistory interface {
3435 DeleteByDomain(ctx context.Context, domain string) error
3536 }
3637
37-// FavoriteCreateInput contains fields needed to create a favorite from a UI port.
38-type FavoriteCreateInput struct {
39- URL string `json:"url"`
40- Title string `json:"title"`
41- FaviconURL string `json:"favicon_url"`
42- FolderID *entity.FolderID `json:"folder_id"`
43- Tags []entity.TagID `json:"tags"`
44-}
45-
46-// FavoriteUpdateInput contains editable favorite metadata.
47-type FavoriteUpdateInput struct {
48- ID entity.FavoriteID `json:"id"`
49- Title string `json:"title"`
50- FaviconURL string `json:"favicon_url"`
51- FolderID *entity.FolderID `json:"folder_id"`
52- ShortcutKey *int `json:"shortcut_key"`
53-}
54-
5538 // HomepageFavorites provides favorites/folders/tags operations
5639 // needed by the WebUI homepage handlers.
5740 type HomepageFavorites interface {
5841 GetAll(ctx context.Context) ([]*entity.Favorite, error)
59- AddFavorite(ctx context.Context, input FavoriteCreateInput) (*entity.Favorite, error)
60- UpdateFavorite(ctx context.Context, input FavoriteUpdateInput) (*entity.Favorite, error)
42+ AddFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error)
43+ UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error)
6144 DeleteFavorite(ctx context.Context, id entity.FavoriteID) error
6245 SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error
6346 GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error)
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "time"5 "time"
6 6
7+ "github.com/bnema/dumber/internal/application/dto"
7 "github.com/bnema/dumber/internal/domain/entity"8 "github.com/bnema/dumber/internal/domain/entity"
8 )9 )
9 10
@@ -34,30 +35,12 @@ type HomepageHistory interface {
34 DeleteByDomain(ctx context.Context, domain string) error35 DeleteByDomain(ctx context.Context, domain string) error
35 }36 }
36 37
37-// FavoriteCreateInput contains fields needed to create a favorite from a UI port.
38-type FavoriteCreateInput struct {
39- URL string `json:"url"`
40- Title string `json:"title"`
41- FaviconURL string `json:"favicon_url"`
42- FolderID *entity.FolderID `json:"folder_id"`
43- Tags []entity.TagID `json:"tags"`
44-}
45-
46-// FavoriteUpdateInput contains editable favorite metadata.
47-type FavoriteUpdateInput struct {
48- ID entity.FavoriteID `json:"id"`
49- Title string `json:"title"`
50- FaviconURL string `json:"favicon_url"`
51- FolderID *entity.FolderID `json:"folder_id"`
52- ShortcutKey *int `json:"shortcut_key"`
53-}
54-
55 // HomepageFavorites provides favorites/folders/tags operations38 // HomepageFavorites provides favorites/folders/tags operations
56 // needed by the WebUI homepage handlers.39 // needed by the WebUI homepage handlers.
57 type HomepageFavorites interface {40 type HomepageFavorites interface {
58 GetAll(ctx context.Context) ([]*entity.Favorite, error)41 GetAll(ctx context.Context) ([]*entity.Favorite, error)
59- AddFavorite(ctx context.Context, input FavoriteCreateInput) (*entity.Favorite, error)42+ AddFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error)
60- UpdateFavorite(ctx context.Context, input FavoriteUpdateInput) (*entity.Favorite, error)43+ UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error)
61 DeleteFavorite(ctx context.Context, id entity.FavoriteID) error44 DeleteFavorite(ctx context.Context, id entity.FavoriteID) error
62 SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error45 SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error
63 GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error)46 GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error)
M internal/application/port/mocks/mocks.go +18 -18
@@ -7291,7 +7291,7 @@ func (_m *MockHomepageFavorites) EXPECT() *MockHomepageFavorites_Expecter {
72917291 }
72927292
72937293 // AddFavorite provides a mock function for the type MockHomepageFavorites
7294-func (_mock *MockHomepageFavorites) AddFavorite(ctx context.Context, input port.FavoriteCreateInput) (*entity.Favorite, error) {
7294+func (_mock *MockHomepageFavorites) AddFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
72957295 ret := _mock.Called(ctx, input)
72967296
72977297 if len(ret) == 0 {
@@ -7300,17 +7300,17 @@ func (_mock *MockHomepageFavorites) AddFavorite(ctx context.Context, input port.
73007300
73017301 var r0 *entity.Favorite
73027302 var r1 error
7303- if returnFunc, ok := ret.Get(0).(func(context.Context, port.FavoriteCreateInput) (*entity.Favorite, error)); ok {
7303+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.FavoriteCreateInput) (*entity.Favorite, error)); ok {
73047304 return returnFunc(ctx, input)
73057305 }
7306- if returnFunc, ok := ret.Get(0).(func(context.Context, port.FavoriteCreateInput) *entity.Favorite); ok {
7306+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.FavoriteCreateInput) *entity.Favorite); ok {
73077307 r0 = returnFunc(ctx, input)
73087308 } else {
73097309 if ret.Get(0) != nil {
73107310 r0 = ret.Get(0).(*entity.Favorite)
73117311 }
73127312 }
7313- if returnFunc, ok := ret.Get(1).(func(context.Context, port.FavoriteCreateInput) error); ok {
7313+ if returnFunc, ok := ret.Get(1).(func(context.Context, dto.FavoriteCreateInput) error); ok {
73147314 r1 = returnFunc(ctx, input)
73157315 } else {
73167316 r1 = ret.Error(1)
@@ -7325,20 +7325,20 @@ type MockHomepageFavorites_AddFavorite_Call struct {
73257325
73267326 // AddFavorite is a helper method to define mock.On call
73277327 // - ctx context.Context
7328-// - input port.FavoriteCreateInput
7328+// - input dto.FavoriteCreateInput
73297329 func (_e *MockHomepageFavorites_Expecter) AddFavorite(ctx interface{}, input interface{}) *MockHomepageFavorites_AddFavorite_Call {
73307330 return &MockHomepageFavorites_AddFavorite_Call{Call: _e.mock.On("AddFavorite", ctx, input)}
73317331 }
73327332
7333-func (_c *MockHomepageFavorites_AddFavorite_Call) Run(run func(ctx context.Context, input port.FavoriteCreateInput)) *MockHomepageFavorites_AddFavorite_Call {
7333+func (_c *MockHomepageFavorites_AddFavorite_Call) Run(run func(ctx context.Context, input dto.FavoriteCreateInput)) *MockHomepageFavorites_AddFavorite_Call {
73347334 _c.Call.Run(func(args mock.Arguments) {
73357335 var arg0 context.Context
73367336 if args[0] != nil {
73377337 arg0 = args[0].(context.Context)
73387338 }
7339- var arg1 port.FavoriteCreateInput
7339+ var arg1 dto.FavoriteCreateInput
73407340 if args[1] != nil {
7341- arg1 = args[1].(port.FavoriteCreateInput)
7341+ arg1 = args[1].(dto.FavoriteCreateInput)
73427342 }
73437343 run(
73447344 arg0,
@@ -7353,7 +7353,7 @@ func (_c *MockHomepageFavorites_AddFavorite_Call) Return(favorite *entity.Favori
73537353 return _c
73547354 }
73557355
7356-func (_c *MockHomepageFavorites_AddFavorite_Call) RunAndReturn(run func(ctx context.Context, input port.FavoriteCreateInput) (*entity.Favorite, error)) *MockHomepageFavorites_AddFavorite_Call {
7356+func (_c *MockHomepageFavorites_AddFavorite_Call) RunAndReturn(run func(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error)) *MockHomepageFavorites_AddFavorite_Call {
73577357 _c.Call.Return(run)
73587358 return _c
73597359 }
@@ -8190,7 +8190,7 @@ func (_c *MockHomepageFavorites_UntagFavorite_Call) RunAndReturn(run func(ctx co
81908190 }
81918191
81928192 // UpdateFavorite provides a mock function for the type MockHomepageFavorites
8193-func (_mock *MockHomepageFavorites) UpdateFavorite(ctx context.Context, input port.FavoriteUpdateInput) (*entity.Favorite, error) {
8193+func (_mock *MockHomepageFavorites) UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) {
81948194 ret := _mock.Called(ctx, input)
81958195
81968196 if len(ret) == 0 {
@@ -8199,17 +8199,17 @@ func (_mock *MockHomepageFavorites) UpdateFavorite(ctx context.Context, input po
81998199
82008200 var r0 *entity.Favorite
82018201 var r1 error
8202- if returnFunc, ok := ret.Get(0).(func(context.Context, port.FavoriteUpdateInput) (*entity.Favorite, error)); ok {
8202+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.FavoriteUpdateInput) (*entity.Favorite, error)); ok {
82038203 return returnFunc(ctx, input)
82048204 }
8205- if returnFunc, ok := ret.Get(0).(func(context.Context, port.FavoriteUpdateInput) *entity.Favorite); ok {
8205+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.FavoriteUpdateInput) *entity.Favorite); ok {
82068206 r0 = returnFunc(ctx, input)
82078207 } else {
82088208 if ret.Get(0) != nil {
82098209 r0 = ret.Get(0).(*entity.Favorite)
82108210 }
82118211 }
8212- if returnFunc, ok := ret.Get(1).(func(context.Context, port.FavoriteUpdateInput) error); ok {
8212+ if returnFunc, ok := ret.Get(1).(func(context.Context, dto.FavoriteUpdateInput) error); ok {
82138213 r1 = returnFunc(ctx, input)
82148214 } else {
82158215 r1 = ret.Error(1)
@@ -8224,20 +8224,20 @@ type MockHomepageFavorites_UpdateFavorite_Call struct {
82248224
82258225 // UpdateFavorite is a helper method to define mock.On call
82268226 // - ctx context.Context
8227-// - input port.FavoriteUpdateInput
8227+// - input dto.FavoriteUpdateInput
82288228 func (_e *MockHomepageFavorites_Expecter) UpdateFavorite(ctx interface{}, input interface{}) *MockHomepageFavorites_UpdateFavorite_Call {
82298229 return &MockHomepageFavorites_UpdateFavorite_Call{Call: _e.mock.On("UpdateFavorite", ctx, input)}
82308230 }
82318231
8232-func (_c *MockHomepageFavorites_UpdateFavorite_Call) Run(run func(ctx context.Context, input port.FavoriteUpdateInput)) *MockHomepageFavorites_UpdateFavorite_Call {
8232+func (_c *MockHomepageFavorites_UpdateFavorite_Call) Run(run func(ctx context.Context, input dto.FavoriteUpdateInput)) *MockHomepageFavorites_UpdateFavorite_Call {
82338233 _c.Call.Run(func(args mock.Arguments) {
82348234 var arg0 context.Context
82358235 if args[0] != nil {
82368236 arg0 = args[0].(context.Context)
82378237 }
8238- var arg1 port.FavoriteUpdateInput
8238+ var arg1 dto.FavoriteUpdateInput
82398239 if args[1] != nil {
8240- arg1 = args[1].(port.FavoriteUpdateInput)
8240+ arg1 = args[1].(dto.FavoriteUpdateInput)
82418241 }
82428242 run(
82438243 arg0,
@@ -8252,7 +8252,7 @@ func (_c *MockHomepageFavorites_UpdateFavorite_Call) Return(favorite *entity.Fav
82528252 return _c
82538253 }
82548254
8255-func (_c *MockHomepageFavorites_UpdateFavorite_Call) RunAndReturn(run func(ctx context.Context, input port.FavoriteUpdateInput) (*entity.Favorite, error)) *MockHomepageFavorites_UpdateFavorite_Call {
8255+func (_c *MockHomepageFavorites_UpdateFavorite_Call) RunAndReturn(run func(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error)) *MockHomepageFavorites_UpdateFavorite_Call {
82568256 _c.Call.Return(run)
82578257 return _c
82588258 }
@@ -7291,7 +7291,7 @@ func (_m *MockHomepageFavorites) EXPECT() *MockHomepageFavorites_Expecter {
7291 }7291 }
7292 7292
7293 // AddFavorite provides a mock function for the type MockHomepageFavorites7293 // AddFavorite provides a mock function for the type MockHomepageFavorites
7294-func (_mock *MockHomepageFavorites) AddFavorite(ctx context.Context, input port.FavoriteCreateInput) (*entity.Favorite, error) {7294+func (_mock *MockHomepageFavorites) AddFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
7295 ret := _mock.Called(ctx, input)7295 ret := _mock.Called(ctx, input)
7296 7296
7297 if len(ret) == 0 {7297 if len(ret) == 0 {
@@ -7300,17 +7300,17 @@ func (_mock *MockHomepageFavorites) AddFavorite(ctx context.Context, input port.
7300 7300
7301 var r0 *entity.Favorite7301 var r0 *entity.Favorite
7302 var r1 error7302 var r1 error
7303- if returnFunc, ok := ret.Get(0).(func(context.Context, port.FavoriteCreateInput) (*entity.Favorite, error)); ok {7303+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.FavoriteCreateInput) (*entity.Favorite, error)); ok {
7304 return returnFunc(ctx, input)7304 return returnFunc(ctx, input)
7305 }7305 }
7306- if returnFunc, ok := ret.Get(0).(func(context.Context, port.FavoriteCreateInput) *entity.Favorite); ok {7306+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.FavoriteCreateInput) *entity.Favorite); ok {
7307 r0 = returnFunc(ctx, input)7307 r0 = returnFunc(ctx, input)
7308 } else {7308 } else {
7309 if ret.Get(0) != nil {7309 if ret.Get(0) != nil {
7310 r0 = ret.Get(0).(*entity.Favorite)7310 r0 = ret.Get(0).(*entity.Favorite)
7311 }7311 }
7312 }7312 }
7313- if returnFunc, ok := ret.Get(1).(func(context.Context, port.FavoriteCreateInput) error); ok {7313+ if returnFunc, ok := ret.Get(1).(func(context.Context, dto.FavoriteCreateInput) error); ok {
7314 r1 = returnFunc(ctx, input)7314 r1 = returnFunc(ctx, input)
7315 } else {7315 } else {
7316 r1 = ret.Error(1)7316 r1 = ret.Error(1)
@@ -7325,20 +7325,20 @@ type MockHomepageFavorites_AddFavorite_Call struct {
7325 7325
7326 // AddFavorite is a helper method to define mock.On call7326 // AddFavorite is a helper method to define mock.On call
7327 // - ctx context.Context7327 // - ctx context.Context
7328-// - input port.FavoriteCreateInput7328+// - input dto.FavoriteCreateInput
7329 func (_e *MockHomepageFavorites_Expecter) AddFavorite(ctx interface{}, input interface{}) *MockHomepageFavorites_AddFavorite_Call {7329 func (_e *MockHomepageFavorites_Expecter) AddFavorite(ctx interface{}, input interface{}) *MockHomepageFavorites_AddFavorite_Call {
7330 return &MockHomepageFavorites_AddFavorite_Call{Call: _e.mock.On("AddFavorite", ctx, input)}7330 return &MockHomepageFavorites_AddFavorite_Call{Call: _e.mock.On("AddFavorite", ctx, input)}
7331 }7331 }
7332 7332
7333-func (_c *MockHomepageFavorites_AddFavorite_Call) Run(run func(ctx context.Context, input port.FavoriteCreateInput)) *MockHomepageFavorites_AddFavorite_Call {7333+func (_c *MockHomepageFavorites_AddFavorite_Call) Run(run func(ctx context.Context, input dto.FavoriteCreateInput)) *MockHomepageFavorites_AddFavorite_Call {
7334 _c.Call.Run(func(args mock.Arguments) {7334 _c.Call.Run(func(args mock.Arguments) {
7335 var arg0 context.Context7335 var arg0 context.Context
7336 if args[0] != nil {7336 if args[0] != nil {
7337 arg0 = args[0].(context.Context)7337 arg0 = args[0].(context.Context)
7338 }7338 }
7339- var arg1 port.FavoriteCreateInput7339+ var arg1 dto.FavoriteCreateInput
7340 if args[1] != nil {7340 if args[1] != nil {
7341- arg1 = args[1].(port.FavoriteCreateInput)7341+ arg1 = args[1].(dto.FavoriteCreateInput)
7342 }7342 }
7343 run(7343 run(
7344 arg0,7344 arg0,
@@ -7353,7 +7353,7 @@ func (_c *MockHomepageFavorites_AddFavorite_Call) Return(favorite *entity.Favori
7353 return _c7353 return _c
7354 }7354 }
7355 7355
7356-func (_c *MockHomepageFavorites_AddFavorite_Call) RunAndReturn(run func(ctx context.Context, input port.FavoriteCreateInput) (*entity.Favorite, error)) *MockHomepageFavorites_AddFavorite_Call {7356+func (_c *MockHomepageFavorites_AddFavorite_Call) RunAndReturn(run func(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error)) *MockHomepageFavorites_AddFavorite_Call {
7357 _c.Call.Return(run)7357 _c.Call.Return(run)
7358 return _c7358 return _c
7359 }7359 }
@@ -8190,7 +8190,7 @@ func (_c *MockHomepageFavorites_UntagFavorite_Call) RunAndReturn(run func(ctx co
8190 }8190 }
8191 8191
8192 // UpdateFavorite provides a mock function for the type MockHomepageFavorites8192 // UpdateFavorite provides a mock function for the type MockHomepageFavorites
8193-func (_mock *MockHomepageFavorites) UpdateFavorite(ctx context.Context, input port.FavoriteUpdateInput) (*entity.Favorite, error) {8193+func (_mock *MockHomepageFavorites) UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) {
8194 ret := _mock.Called(ctx, input)8194 ret := _mock.Called(ctx, input)
8195 8195
8196 if len(ret) == 0 {8196 if len(ret) == 0 {
@@ -8199,17 +8199,17 @@ func (_mock *MockHomepageFavorites) UpdateFavorite(ctx context.Context, input po
8199 8199
8200 var r0 *entity.Favorite8200 var r0 *entity.Favorite
8201 var r1 error8201 var r1 error
8202- if returnFunc, ok := ret.Get(0).(func(context.Context, port.FavoriteUpdateInput) (*entity.Favorite, error)); ok {8202+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.FavoriteUpdateInput) (*entity.Favorite, error)); ok {
8203 return returnFunc(ctx, input)8203 return returnFunc(ctx, input)
8204 }8204 }
8205- if returnFunc, ok := ret.Get(0).(func(context.Context, port.FavoriteUpdateInput) *entity.Favorite); ok {8205+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.FavoriteUpdateInput) *entity.Favorite); ok {
8206 r0 = returnFunc(ctx, input)8206 r0 = returnFunc(ctx, input)
8207 } else {8207 } else {
8208 if ret.Get(0) != nil {8208 if ret.Get(0) != nil {
8209 r0 = ret.Get(0).(*entity.Favorite)8209 r0 = ret.Get(0).(*entity.Favorite)
8210 }8210 }
8211 }8211 }
8212- if returnFunc, ok := ret.Get(1).(func(context.Context, port.FavoriteUpdateInput) error); ok {8212+ if returnFunc, ok := ret.Get(1).(func(context.Context, dto.FavoriteUpdateInput) error); ok {
8213 r1 = returnFunc(ctx, input)8213 r1 = returnFunc(ctx, input)
8214 } else {8214 } else {
8215 r1 = ret.Error(1)8215 r1 = ret.Error(1)
@@ -8224,20 +8224,20 @@ type MockHomepageFavorites_UpdateFavorite_Call struct {
8224 8224
8225 // UpdateFavorite is a helper method to define mock.On call8225 // UpdateFavorite is a helper method to define mock.On call
8226 // - ctx context.Context8226 // - ctx context.Context
8227-// - input port.FavoriteUpdateInput8227+// - input dto.FavoriteUpdateInput
8228 func (_e *MockHomepageFavorites_Expecter) UpdateFavorite(ctx interface{}, input interface{}) *MockHomepageFavorites_UpdateFavorite_Call {8228 func (_e *MockHomepageFavorites_Expecter) UpdateFavorite(ctx interface{}, input interface{}) *MockHomepageFavorites_UpdateFavorite_Call {
8229 return &MockHomepageFavorites_UpdateFavorite_Call{Call: _e.mock.On("UpdateFavorite", ctx, input)}8229 return &MockHomepageFavorites_UpdateFavorite_Call{Call: _e.mock.On("UpdateFavorite", ctx, input)}
8230 }8230 }
8231 8231
8232-func (_c *MockHomepageFavorites_UpdateFavorite_Call) Run(run func(ctx context.Context, input port.FavoriteUpdateInput)) *MockHomepageFavorites_UpdateFavorite_Call {8232+func (_c *MockHomepageFavorites_UpdateFavorite_Call) Run(run func(ctx context.Context, input dto.FavoriteUpdateInput)) *MockHomepageFavorites_UpdateFavorite_Call {
8233 _c.Call.Run(func(args mock.Arguments) {8233 _c.Call.Run(func(args mock.Arguments) {
8234 var arg0 context.Context8234 var arg0 context.Context
8235 if args[0] != nil {8235 if args[0] != nil {
8236 arg0 = args[0].(context.Context)8236 arg0 = args[0].(context.Context)
8237 }8237 }
8238- var arg1 port.FavoriteUpdateInput8238+ var arg1 dto.FavoriteUpdateInput
8239 if args[1] != nil {8239 if args[1] != nil {
8240- arg1 = args[1].(port.FavoriteUpdateInput)8240+ arg1 = args[1].(dto.FavoriteUpdateInput)
8241 }8241 }
8242 run(8242 run(
8243 arg0,8243 arg0,
@@ -8252,7 +8252,7 @@ func (_c *MockHomepageFavorites_UpdateFavorite_Call) Return(favorite *entity.Fav
8252 return _c8252 return _c
8253 }8253 }
8254 8254
8255-func (_c *MockHomepageFavorites_UpdateFavorite_Call) RunAndReturn(run func(ctx context.Context, input port.FavoriteUpdateInput) (*entity.Favorite, error)) *MockHomepageFavorites_UpdateFavorite_Call {8255+func (_c *MockHomepageFavorites_UpdateFavorite_Call) RunAndReturn(run func(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error)) *MockHomepageFavorites_UpdateFavorite_Call {
8256 _c.Call.Return(run)8256 _c.Call.Return(run)
8257 return _c8257 return _c
8258 }8258 }
M internal/application/port/systemviews.go +2 -2
@@ -25,8 +25,8 @@ type SystemviewHistoryService interface {
2525 // SystemviewFavoritesService exposes favorite, folder, and tag operations.
2626 type SystemviewFavoritesService interface {
2727 List(ctx context.Context) ([]*entity.Favorite, error)
28- CreateFavorite(ctx context.Context, input FavoriteCreateInput) (*entity.Favorite, error)
29- UpdateFavorite(ctx context.Context, input FavoriteUpdateInput) (*entity.Favorite, error)
28+ CreateFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error)
29+ UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error)
3030 DeleteFavorite(ctx context.Context, id int64) error
3131 ListFolders(ctx context.Context) ([]*entity.Folder, error)
3232 ListTags(ctx context.Context) ([]*entity.Tag, error)
@@ -25,8 +25,8 @@ type SystemviewHistoryService interface {
25 // SystemviewFavoritesService exposes favorite, folder, and tag operations.25 // SystemviewFavoritesService exposes favorite, folder, and tag operations.
26 type SystemviewFavoritesService interface {26 type SystemviewFavoritesService interface {
27 List(ctx context.Context) ([]*entity.Favorite, error)27 List(ctx context.Context) ([]*entity.Favorite, error)
28- CreateFavorite(ctx context.Context, input FavoriteCreateInput) (*entity.Favorite, error)28+ CreateFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error)
29- UpdateFavorite(ctx context.Context, input FavoriteUpdateInput) (*entity.Favorite, error)29+ UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error)
30 DeleteFavorite(ctx context.Context, id int64) error30 DeleteFavorite(ctx context.Context, id int64) error
31 ListFolders(ctx context.Context) ([]*entity.Folder, error)31 ListFolders(ctx context.Context) ([]*entity.Folder, error)
32 ListTags(ctx context.Context) ([]*entity.Tag, error)32 ListTags(ctx context.Context) ([]*entity.Tag, error)
M internal/application/usecase/manage_favorites.go +3 -2
@@ -9,6 +9,7 @@ import (
99 "sync"
1010 "time"
1111
12+ "github.com/bnema/dumber/internal/application/dto"
1213 "github.com/bnema/dumber/internal/application/port"
1314 "github.com/bnema/dumber/internal/domain/entity"
1415 "github.com/bnema/dumber/internal/domain/repository"
@@ -100,7 +101,7 @@ func (uc *ManageFavoritesUseCase) addNewFavorite(ctx context.Context, input AddF
100101 }
101102
102103 // AddFavorite creates a favorite from the UI-facing application port.
103-func (uc *ManageFavoritesUseCase) AddFavorite(ctx context.Context, input port.FavoriteCreateInput) (*entity.Favorite, error) {
104+func (uc *ManageFavoritesUseCase) AddFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
104105 favoriteURL, err := normalizeFavoriteURL(input.URL)
105106 if err != nil {
106107 return nil, err
@@ -177,7 +178,7 @@ func normalizeFavoriteURL(raw string) (string, error) {
177178 }
178179
179180 // UpdateFavorite updates editable favorite metadata from the UI-facing application port.
180-func (uc *ManageFavoritesUseCase) UpdateFavorite(ctx context.Context, input port.FavoriteUpdateInput) (*entity.Favorite, error) {
181+func (uc *ManageFavoritesUseCase) UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) {
181182 if input.ID <= 0 {
182183 return nil, fmt.Errorf("favorite ID is required")
183184 }
@@ -9,6 +9,7 @@ import (
9 "sync"9 "sync"
10 "time"10 "time"
11 11
12+ "github.com/bnema/dumber/internal/application/dto"
12 "github.com/bnema/dumber/internal/application/port"13 "github.com/bnema/dumber/internal/application/port"
13 "github.com/bnema/dumber/internal/domain/entity"14 "github.com/bnema/dumber/internal/domain/entity"
14 "github.com/bnema/dumber/internal/domain/repository"15 "github.com/bnema/dumber/internal/domain/repository"
@@ -100,7 +101,7 @@ func (uc *ManageFavoritesUseCase) addNewFavorite(ctx context.Context, input AddF
100 }101 }
101 102
102 // AddFavorite creates a favorite from the UI-facing application port.103 // AddFavorite creates a favorite from the UI-facing application port.
103-func (uc *ManageFavoritesUseCase) AddFavorite(ctx context.Context, input port.FavoriteCreateInput) (*entity.Favorite, error) {104+func (uc *ManageFavoritesUseCase) AddFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
104 favoriteURL, err := normalizeFavoriteURL(input.URL)105 favoriteURL, err := normalizeFavoriteURL(input.URL)
105 if err != nil {106 if err != nil {
106 return nil, err107 return nil, err
@@ -177,7 +178,7 @@ func normalizeFavoriteURL(raw string) (string, error) {
177 }178 }
178 179
179 // UpdateFavorite updates editable favorite metadata from the UI-facing application port.180 // UpdateFavorite updates editable favorite metadata from the UI-facing application port.
180-func (uc *ManageFavoritesUseCase) UpdateFavorite(ctx context.Context, input port.FavoriteUpdateInput) (*entity.Favorite, error) {181+func (uc *ManageFavoritesUseCase) UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) {
181 if input.ID <= 0 {182 if input.ID <= 0 {
182 return nil, fmt.Errorf("favorite ID is required")183 return nil, fmt.Errorf("favorite ID is required")
183 }184 }
M internal/application/usecase/manage_favorites_test.go +4 -4
@@ -4,7 +4,7 @@ import (
44 "errors"
55 "testing"
66
7- "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/application/dto"
88 "github.com/bnema/dumber/internal/application/usecase"
99 "github.com/bnema/dumber/internal/domain/entity"
1010 repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
@@ -382,7 +382,7 @@ func TestManageFavoritesUseCase_AddFavoriteNormalizesURL(t *testing.T) {
382382
383383 uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
384384
385- fav, err := uc.AddFavorite(ctx, port.FavoriteCreateInput{URL: "example.com", Title: " Example "})
385+ fav, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: "example.com", Title: " Example "})
386386 require.NoError(t, err)
387387 require.NotNil(t, fav)
388388 assert.Equal(t, "https://example.com", fav.URL)
@@ -407,7 +407,7 @@ func TestManageFavoritesUseCase_AddFavoriteRejectsUnsafeOrMalformedURL(t *testin
407407 tagRepo := repomocks.NewMockTagRepository(t)
408408 uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
409409
410- _, err := uc.AddFavorite(ctx, port.FavoriteCreateInput{URL: raw})
410+ _, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: raw})
411411 require.Error(t, err)
412412 })
413413 }
@@ -428,7 +428,7 @@ func TestManageFavoritesUseCase_AddFavoriteNormalizesOpaqueDumbRoute(t *testing.
428428
429429 uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
430430
431- fav, err := uc.AddFavorite(ctx, port.FavoriteCreateInput{URL: "dumb:history", Title: "History"})
431+ fav, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: "dumb:history", Title: "History"})
432432 require.NoError(t, err)
433433 require.NotNil(t, fav)
434434 assert.Equal(t, "dumb://history", fav.URL)
@@ -4,7 +4,7 @@ import (
4 "errors"4 "errors"
5 "testing"5 "testing"
6 6
7- "github.com/bnema/dumber/internal/application/port"7+ "github.com/bnema/dumber/internal/application/dto"
8 "github.com/bnema/dumber/internal/application/usecase"8 "github.com/bnema/dumber/internal/application/usecase"
9 "github.com/bnema/dumber/internal/domain/entity"9 "github.com/bnema/dumber/internal/domain/entity"
10 repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"10 repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
@@ -382,7 +382,7 @@ func TestManageFavoritesUseCase_AddFavoriteNormalizesURL(t *testing.T) {
382 382
383 uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)383 uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
384 384
385- fav, err := uc.AddFavorite(ctx, port.FavoriteCreateInput{URL: "example.com", Title: " Example "})385+ fav, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: "example.com", Title: " Example "})
386 require.NoError(t, err)386 require.NoError(t, err)
387 require.NotNil(t, fav)387 require.NotNil(t, fav)
388 assert.Equal(t, "https://example.com", fav.URL)388 assert.Equal(t, "https://example.com", fav.URL)
@@ -407,7 +407,7 @@ func TestManageFavoritesUseCase_AddFavoriteRejectsUnsafeOrMalformedURL(t *testin
407 tagRepo := repomocks.NewMockTagRepository(t)407 tagRepo := repomocks.NewMockTagRepository(t)
408 uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)408 uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
409 409
410- _, err := uc.AddFavorite(ctx, port.FavoriteCreateInput{URL: raw})410+ _, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: raw})
411 require.Error(t, err)411 require.Error(t, err)
412 })412 })
413 }413 }
@@ -428,7 +428,7 @@ func TestManageFavoritesUseCase_AddFavoriteNormalizesOpaqueDumbRoute(t *testing.
428 428
429 uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)429 uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
430 430
431- fav, err := uc.AddFavorite(ctx, port.FavoriteCreateInput{URL: "dumb:history", Title: "History"})431+ fav, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: "dumb:history", Title: "History"})
432 require.NoError(t, err)432 require.NoError(t, err)
433 require.NotNil(t, fav)433 require.NotNil(t, fav)
434 assert.Equal(t, "dumb://history", fav.URL)434 assert.Equal(t, "dumb://history", fav.URL)
M internal/application/usecase/search_history.go +5 -0
@@ -3,6 +3,7 @@ package usecase
33 import (
44 "context"
55 "fmt"
6+ "strings"
67 "time"
78
89 "github.com/bnema/dumber/internal/application/port"
@@ -120,7 +121,11 @@ func (uc *SearchHistoryUseCase) GetRecentWindow(ctx context.Context, before time
120121 hasMore bool
121122 err error
122123 )
124+ originalDomain := domain
123125 domain = domainurl.CanonicalDomain(domain)
126+ if strings.TrimSpace(originalDomain) != "" && domain == "" {
127+ return nil, fmt.Errorf("domain is required")
128+ }
124129 if domain != "" {
125130 entries, err = uc.historyRepo.GetRecentWindowByDomain(ctx, domain, before, after)
126131 if err == nil {
@@ -3,6 +3,7 @@ package usecase
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "strings"
6 "time"7 "time"
7 8
8 "github.com/bnema/dumber/internal/application/port"9 "github.com/bnema/dumber/internal/application/port"
@@ -120,7 +121,11 @@ func (uc *SearchHistoryUseCase) GetRecentWindow(ctx context.Context, before time
120 hasMore bool121 hasMore bool
121 err error122 err error
122 )123 )
124+ originalDomain := domain
123 domain = domainurl.CanonicalDomain(domain)125 domain = domainurl.CanonicalDomain(domain)
126+ if strings.TrimSpace(originalDomain) != "" && domain == "" {
127+ return nil, fmt.Errorf("domain is required")
128+ }
124 if domain != "" {129 if domain != "" {
125 entries, err = uc.historyRepo.GetRecentWindowByDomain(ctx, domain, before, after)130 entries, err = uc.historyRepo.GetRecentWindowByDomain(ctx, domain, before, after)
126 if err == nil {131 if err == nil {
M internal/application/usecase/search_history_test.go +12 -0
@@ -38,6 +38,18 @@ func TestSearchHistoryUseCase_GetRecent_NegativeLimitDefaultsToPageSize(t *testi
3838 assert.Empty(t, result)
3939 }
4040
41+func TestSearchHistoryUseCase_GetRecentWindow_InvalidDomainReturnsValidationError(t *testing.T) {
42+ ctx := testContext()
43+ historyRepo := repomocks.NewMockHistoryRepository(t)
44+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
45+
46+ result, err := uc.GetRecentWindow(ctx, time.Now(), "///")
47+
48+ require.Error(t, err)
49+ assert.Nil(t, result)
50+ assert.Contains(t, err.Error(), "domain is required")
51+}
52+
4153 func TestSearchHistoryUseCase_GetRecentSince_ReturnsEntries(t *testing.T) {
4254 ctx := testContext()
4355
@@ -38,6 +38,18 @@ func TestSearchHistoryUseCase_GetRecent_NegativeLimitDefaultsToPageSize(t *testi
38 assert.Empty(t, result)38 assert.Empty(t, result)
39 }39 }
40 40
41+func TestSearchHistoryUseCase_GetRecentWindow_InvalidDomainReturnsValidationError(t *testing.T) {
42+ ctx := testContext()
43+ historyRepo := repomocks.NewMockHistoryRepository(t)
44+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
45+
46+ result, err := uc.GetRecentWindow(ctx, time.Now(), "///")
47+
48+ require.Error(t, err)
49+ assert.Nil(t, result)
50+ assert.Contains(t, err.Error(), "domain is required")
51+}
52+
41 func TestSearchHistoryUseCase_GetRecentSince_ReturnsEntries(t *testing.T) {53 func TestSearchHistoryUseCase_GetRecentSince_ReturnsEntries(t *testing.T) {
42 ctx := testContext()54 ctx := testContext()
43 55
M internal/infrastructure/cef/scheme_handler.go +6 -19
@@ -255,7 +255,7 @@ func (h *dumbSchemeHandler) handleAPIPost(browser purecef.Browser, path string,
255255 }
256256
257257 func (h *dumbSchemeHandler) handleMessageAPI(request purecef.Request) purecef.ResourceHandler {
258- if denied := h.rejectForbiddenAPIOrigin(request); denied != nil {
258+ if denied := h.rejectUntrustedSystemviewRequester(request); denied != nil {
259259 return denied
260260 }
261261 body := readBodyFromHeader(request)
@@ -278,6 +278,10 @@ func (h *dumbSchemeHandler) rejectForbiddenAPIOrigin(request purecef.Request) pu
278278 }
279279
280280 func (h *dumbSchemeHandler) rejectUntrustedConfigRequester(request purecef.Request) purecef.ResourceHandler {
281+ return h.rejectUntrustedSystemviewRequester(request)
282+}
283+
284+func (h *dumbSchemeHandler) rejectUntrustedSystemviewRequester(request purecef.Request) purecef.ResourceHandler {
281285 if request == nil {
282286 return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
283287 }
@@ -333,24 +337,7 @@ func (h *dumbSchemeHandler) handleFaviconAPI(request purecef.Request) purecef.Re
333337 }
334338
335339 func (h *dumbSchemeHandler) rejectUntrustedFaviconRequester(request purecef.Request) purecef.ResourceHandler {
336- if request == nil {
337- return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
338- }
339- origin := strings.TrimSpace(request.GetHeaderByName("Origin"))
340- if origin != "" {
341- if isTrustedSystemviewURL(origin) {
342- return nil
343- }
344- return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
345- }
346- referrer := strings.TrimSpace(request.GetReferrerURL())
347- if referrer == "" {
348- referrer = strings.TrimSpace(request.GetHeaderByName("Referer"))
349- }
350- if !isTrustedSystemviewURL(referrer) {
351- return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
352- }
353- return nil
340+ return h.rejectUntrustedSystemviewRequester(request)
354341 }
355342
356343 func isTrustedSystemviewURL(raw string) bool {
@@ -255,7 +255,7 @@ func (h *dumbSchemeHandler) handleAPIPost(browser purecef.Browser, path string,
255 }255 }
256 256
257 func (h *dumbSchemeHandler) handleMessageAPI(request purecef.Request) purecef.ResourceHandler {257 func (h *dumbSchemeHandler) handleMessageAPI(request purecef.Request) purecef.ResourceHandler {
258- if denied := h.rejectForbiddenAPIOrigin(request); denied != nil {258+ if denied := h.rejectUntrustedSystemviewRequester(request); denied != nil {
259 return denied259 return denied
260 }260 }
261 body := readBodyFromHeader(request)261 body := readBodyFromHeader(request)
@@ -278,6 +278,10 @@ func (h *dumbSchemeHandler) rejectForbiddenAPIOrigin(request purecef.Request) pu
278 }278 }
279 279
280 func (h *dumbSchemeHandler) rejectUntrustedConfigRequester(request purecef.Request) purecef.ResourceHandler {280 func (h *dumbSchemeHandler) rejectUntrustedConfigRequester(request purecef.Request) purecef.ResourceHandler {
281+ return h.rejectUntrustedSystemviewRequester(request)
282+}
283+
284+func (h *dumbSchemeHandler) rejectUntrustedSystemviewRequester(request purecef.Request) purecef.ResourceHandler {
281 if request == nil {285 if request == nil {
282 return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})286 return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
283 }287 }
@@ -333,24 +337,7 @@ func (h *dumbSchemeHandler) handleFaviconAPI(request purecef.Request) purecef.Re
333 }337 }
334 338
335 func (h *dumbSchemeHandler) rejectUntrustedFaviconRequester(request purecef.Request) purecef.ResourceHandler {339 func (h *dumbSchemeHandler) rejectUntrustedFaviconRequester(request purecef.Request) purecef.ResourceHandler {
336- if request == nil {340+ return h.rejectUntrustedSystemviewRequester(request)
337- return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
338- }
339- origin := strings.TrimSpace(request.GetHeaderByName("Origin"))
340- if origin != "" {
341- if isTrustedSystemviewURL(origin) {
342- return nil
343- }
344- return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
345- }
346- referrer := strings.TrimSpace(request.GetReferrerURL())
347- if referrer == "" {
348- referrer = strings.TrimSpace(request.GetHeaderByName("Referer"))
349- }
350- if !isTrustedSystemviewURL(referrer) {
351- return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
352- }
353- return nil
354 }341 }
355 342
356 func isTrustedSystemviewURL(raw string) bool {343 func isTrustedSystemviewURL(raw string) bool {
M internal/infrastructure/cef/scheme_handler_test.go +32 -0
@@ -159,6 +159,38 @@ func TestRejectUntrustedConfigRequesterRequiresTrustedOriginOrReferrer(t *testin
159159 require.NotNil(t, h.rejectUntrustedConfigRequester(untrustedOrigin))
160160 }
161161
162+func TestHandleMessageAPIRequiresTrustedOriginOrReferrer(t *testing.T) {
163+ oldNewResourceHandler := cefNewResourceHandler
164+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
165+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
166+
167+ h, err := newDumbSchemeHandler(context.Background(), NewMessageRouter(context.Background()), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
168+ require.NoError(t, err)
169+
170+ trusted := cefmocks.NewMockRequest(t)
171+ trusted.EXPECT().GetHeaderByName("Origin").Return("").Once()
172+ trusted.EXPECT().GetReferrerURL().Return("dumb://history").Once()
173+ trusted.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(base64.StdEncoding.EncodeToString([]byte(`{"type":"missing"}`))).Once()
174+ trustedHandler := h.handleMessageAPI(trusted)
175+ require.NotNil(t, trustedHandler)
176+
177+ untrusted := cefmocks.NewMockRequest(t)
178+ untrusted.EXPECT().GetHeaderByName("Origin").Return("").Once()
179+ untrusted.EXPECT().GetReferrerURL().Return("").Once()
180+ untrusted.EXPECT().GetHeaderByName("Referer").Return("").Once()
181+ denied := h.handleMessageAPI(untrusted)
182+ require.NotNil(t, denied)
183+
184+ response := cefmocks.NewMockResponse(t)
185+ response.EXPECT().SetStatus(int32(http.StatusForbidden)).Once()
186+ response.EXPECT().SetStatusText(http.StatusText(http.StatusForbidden)).Once()
187+ response.EXPECT().SetMimeType("application/json").Once()
188+ expectPrivateAPIResponseHeaders(response)
189+ var responseLength int64
190+ denied.GetResponseHeaders(response, &responseLength, 0)
191+ require.Positive(t, responseLength)
192+}
193+
162194 func TestHandleConfigAPIUsesPrivateNoCORSHeaders(t *testing.T) {
163195 oldNewResourceHandler := cefNewResourceHandler
164196 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
@@ -159,6 +159,38 @@ func TestRejectUntrustedConfigRequesterRequiresTrustedOriginOrReferrer(t *testin
159 require.NotNil(t, h.rejectUntrustedConfigRequester(untrustedOrigin))159 require.NotNil(t, h.rejectUntrustedConfigRequester(untrustedOrigin))
160 }160 }
161 161
162+func TestHandleMessageAPIRequiresTrustedOriginOrReferrer(t *testing.T) {
163+ oldNewResourceHandler := cefNewResourceHandler
164+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
165+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
166+
167+ h, err := newDumbSchemeHandler(context.Background(), NewMessageRouter(context.Background()), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
168+ require.NoError(t, err)
169+
170+ trusted := cefmocks.NewMockRequest(t)
171+ trusted.EXPECT().GetHeaderByName("Origin").Return("").Once()
172+ trusted.EXPECT().GetReferrerURL().Return("dumb://history").Once()
173+ trusted.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(base64.StdEncoding.EncodeToString([]byte(`{"type":"missing"}`))).Once()
174+ trustedHandler := h.handleMessageAPI(trusted)
175+ require.NotNil(t, trustedHandler)
176+
177+ untrusted := cefmocks.NewMockRequest(t)
178+ untrusted.EXPECT().GetHeaderByName("Origin").Return("").Once()
179+ untrusted.EXPECT().GetReferrerURL().Return("").Once()
180+ untrusted.EXPECT().GetHeaderByName("Referer").Return("").Once()
181+ denied := h.handleMessageAPI(untrusted)
182+ require.NotNil(t, denied)
183+
184+ response := cefmocks.NewMockResponse(t)
185+ response.EXPECT().SetStatus(int32(http.StatusForbidden)).Once()
186+ response.EXPECT().SetStatusText(http.StatusText(http.StatusForbidden)).Once()
187+ response.EXPECT().SetMimeType("application/json").Once()
188+ expectPrivateAPIResponseHeaders(response)
189+ var responseLength int64
190+ denied.GetResponseHeaders(response, &responseLength, 0)
191+ require.Positive(t, responseLength)
192+}
193+
162 func TestHandleConfigAPIUsesPrivateNoCORSHeaders(t *testing.T) {194 func TestHandleConfigAPIUsesPrivateNoCORSHeaders(t *testing.T) {
163 oldNewResourceHandler := cefNewResourceHandler195 oldNewResourceHandler := cefNewResourceHandler
164 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }196 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
M internal/infrastructure/handlers/homepage/favorites.go +3 -2
@@ -6,6 +6,7 @@ import (
66 "fmt"
77 "strings"
88
9+ "github.com/bnema/dumber/internal/application/dto"
910 "github.com/bnema/dumber/internal/application/port"
1011 "github.com/bnema/dumber/internal/domain/entity"
1112 "github.com/bnema/dumber/internal/logging"
@@ -99,7 +100,7 @@ func (h *FavoritesHandlers) HandleCreate() port.WebUIMessageHandler {
99100 return NewErrorResponse(req.RequestID, err), nil
100101 }
101102
102- input := port.FavoriteCreateInput{
103+ input := dto.FavoriteCreateInput{
103104 URL: trimmedURL,
104105 Title: req.Title,
105106 FaviconURL: req.FaviconURL,
@@ -148,7 +149,7 @@ func (h *FavoritesHandlers) HandleUpdate() port.WebUIMessageHandler {
148149 return NewErrorResponse(req.RequestID, err), nil
149150 }
150151
151- favorite, err := h.favoritesUC.UpdateFavorite(ctx, port.FavoriteUpdateInput{
152+ favorite, err := h.favoritesUC.UpdateFavorite(ctx, dto.FavoriteUpdateInput{
152153 ID: entity.FavoriteID(req.ID),
153154 Title: req.Title,
154155 FaviconURL: req.FaviconURL,
@@ -6,6 +6,7 @@ import (
6 "fmt"6 "fmt"
7 "strings"7 "strings"
8 8
9+ "github.com/bnema/dumber/internal/application/dto"
9 "github.com/bnema/dumber/internal/application/port"10 "github.com/bnema/dumber/internal/application/port"
10 "github.com/bnema/dumber/internal/domain/entity"11 "github.com/bnema/dumber/internal/domain/entity"
11 "github.com/bnema/dumber/internal/logging"12 "github.com/bnema/dumber/internal/logging"
@@ -99,7 +100,7 @@ func (h *FavoritesHandlers) HandleCreate() port.WebUIMessageHandler {
99 return NewErrorResponse(req.RequestID, err), nil100 return NewErrorResponse(req.RequestID, err), nil
100 }101 }
101 102
102- input := port.FavoriteCreateInput{103+ input := dto.FavoriteCreateInput{
103 URL: trimmedURL,104 URL: trimmedURL,
104 Title: req.Title,105 Title: req.Title,
105 FaviconURL: req.FaviconURL,106 FaviconURL: req.FaviconURL,
@@ -148,7 +149,7 @@ func (h *FavoritesHandlers) HandleUpdate() port.WebUIMessageHandler {
148 return NewErrorResponse(req.RequestID, err), nil149 return NewErrorResponse(req.RequestID, err), nil
149 }150 }
150 151
151- favorite, err := h.favoritesUC.UpdateFavorite(ctx, port.FavoriteUpdateInput{152+ favorite, err := h.favoritesUC.UpdateFavorite(ctx, dto.FavoriteUpdateInput{
152 ID: entity.FavoriteID(req.ID),153 ID: entity.FavoriteID(req.ID),
153 Title: req.Title,154 Title: req.Title,
154 FaviconURL: req.FaviconURL,155 FaviconURL: req.FaviconURL,
M internal/infrastructure/systemviewsbridge/browser_transport_js.go +48 -23
@@ -97,16 +97,16 @@ type webkitCallbackWaiter struct {
9797 }
9898
9999 // webkitCallbacks uses one page-lifetime dispatcher per native callback name.
100-// Requests sharing the same callback names are intentionally serialized because
101-// older native shims do not always echo requestId; late callbacks without a
102-// matching waiter are dropped instead of targeting released js.Func handles.
100+// Multiple requests may share callback names concurrently. When native payloads
101+// include requestId, dispatch to that waiter; older callbacks without requestId
102+// are resolved FIFO so they cannot target released js.Func handles.
103103 var webkitCallbacks = struct {
104104 sync.Mutex
105105 installed map[string]js.Func
106- waiters map[string]*webkitCallbackWaiter
106+ waiters map[string][]*webkitCallbackWaiter
107107 }{
108108 installed: make(map[string]js.Func),
109- waiters: make(map[string]*webkitCallbackWaiter),
109+ waiters: make(map[string][]*webkitCallbackWaiter),
110110 }
111111
112112 func (*webkitTransport) Available() bool {
@@ -192,21 +192,52 @@ func registerWebKitWaiter(requestID string, plan callbackPlan, resultCh chan web
192192 }
193193 webkitCallbacks.Lock()
194194 defer webkitCallbacks.Unlock()
195- if webkitCallbacks.waiters[plan.success] != nil || webkitCallbacks.waiters[plan.failure] != nil {
196- return nil, fmt.Errorf("webkit callback already pending for %s", plan.success)
197- }
198- webkitCallbacks.waiters[plan.success] = waiter
199- webkitCallbacks.waiters[plan.failure] = waiter
195+ webkitCallbacks.waiters[plan.success] = append(webkitCallbacks.waiters[plan.success], waiter)
196+ webkitCallbacks.waiters[plan.failure] = append(webkitCallbacks.waiters[plan.failure], waiter)
200197 return func() {
201198 webkitCallbacks.Lock()
202199 defer webkitCallbacks.Unlock()
203- if webkitCallbacks.waiters[plan.success] == waiter {
204- delete(webkitCallbacks.waiters, plan.success)
200+ removeWebKitWaiterLocked(waiter)
201+ }, nil
202+}
203+
204+func findWebKitWaiterLocked(name, requestID string) *webkitCallbackWaiter {
205+ waiters := webkitCallbacks.waiters[name]
206+ if len(waiters) == 0 {
207+ return nil
208+ }
209+ if requestID == "" {
210+ return waiters[0]
211+ }
212+ for _, waiter := range waiters {
213+ if waiter != nil && waiter.requestID == requestID {
214+ return waiter
205215 }
206- if webkitCallbacks.waiters[plan.failure] == waiter {
207- delete(webkitCallbacks.waiters, plan.failure)
216+ }
217+ return nil
218+}
219+
220+func removeWebKitWaiterLocked(waiter *webkitCallbackWaiter) {
221+ if waiter == nil {
222+ return
223+ }
224+ remove := func(name string) {
225+ waiters := webkitCallbacks.waiters[name]
226+ for i, candidate := range waiters {
227+ if candidate != waiter {
228+ continue
229+ }
230+ waiters = append(waiters[:i], waiters[i+1:]...)
231+ if len(waiters) == 0 {
232+ delete(webkitCallbacks.waiters, name)
233+ } else {
234+ webkitCallbacks.waiters[name] = waiters
235+ }
236+ return
208237 }
209- }, nil
238+ }
239+ remove(waiter.successName)
240+ remove(waiter.failureName)
210241 }
211242
212243 func handleWebKitCallback(name string, failure bool, args []js.Value) {
@@ -214,15 +245,9 @@ func handleWebKitCallback(name string, failure bool, args []js.Value) {
214245 payloadRequestID := requestIDFromBridgePayload(payload)
215246
216247 webkitCallbacks.Lock()
217- waiter := webkitCallbacks.waiters[name]
218- if waiter != nil && payloadRequestID != "" && waiter.requestID != "" && payloadRequestID != waiter.requestID {
219- webkitCallbacks.Unlock()
220- warnDroppedBridgeResult("webkit callback request mismatch", fmt.Errorf("got %s, want %s", payloadRequestID, waiter.requestID))
221- return
222- }
248+ waiter := findWebKitWaiterLocked(name, payloadRequestID)
223249 if waiter != nil {
224- delete(webkitCallbacks.waiters, waiter.successName)
225- delete(webkitCallbacks.waiters, waiter.failureName)
250+ removeWebKitWaiterLocked(waiter)
226251 }
227252 webkitCallbacks.Unlock()
228253
@@ -97,16 +97,16 @@ type webkitCallbackWaiter struct {
97 }97 }
98 98
99 // webkitCallbacks uses one page-lifetime dispatcher per native callback name.99 // webkitCallbacks uses one page-lifetime dispatcher per native callback name.
100-// Requests sharing the same callback names are intentionally serialized because100+// Multiple requests may share callback names concurrently. When native payloads
101-// older native shims do not always echo requestId; late callbacks without a101+// include requestId, dispatch to that waiter; older callbacks without requestId
102-// matching waiter are dropped instead of targeting released js.Func handles.102+// are resolved FIFO so they cannot target released js.Func handles.
103 var webkitCallbacks = struct {103 var webkitCallbacks = struct {
104 sync.Mutex104 sync.Mutex
105 installed map[string]js.Func105 installed map[string]js.Func
106- waiters map[string]*webkitCallbackWaiter106+ waiters map[string][]*webkitCallbackWaiter
107 }{107 }{
108 installed: make(map[string]js.Func),108 installed: make(map[string]js.Func),
109- waiters: make(map[string]*webkitCallbackWaiter),109+ waiters: make(map[string][]*webkitCallbackWaiter),
110 }110 }
111 111
112 func (*webkitTransport) Available() bool {112 func (*webkitTransport) Available() bool {
@@ -192,21 +192,52 @@ func registerWebKitWaiter(requestID string, plan callbackPlan, resultCh chan web
192 }192 }
193 webkitCallbacks.Lock()193 webkitCallbacks.Lock()
194 defer webkitCallbacks.Unlock()194 defer webkitCallbacks.Unlock()
195- if webkitCallbacks.waiters[plan.success] != nil || webkitCallbacks.waiters[plan.failure] != nil {195+ webkitCallbacks.waiters[plan.success] = append(webkitCallbacks.waiters[plan.success], waiter)
196- return nil, fmt.Errorf("webkit callback already pending for %s", plan.success)196+ webkitCallbacks.waiters[plan.failure] = append(webkitCallbacks.waiters[plan.failure], waiter)
197- }
198- webkitCallbacks.waiters[plan.success] = waiter
199- webkitCallbacks.waiters[plan.failure] = waiter
200 return func() {197 return func() {
201 webkitCallbacks.Lock()198 webkitCallbacks.Lock()
202 defer webkitCallbacks.Unlock()199 defer webkitCallbacks.Unlock()
203- if webkitCallbacks.waiters[plan.success] == waiter {200+ removeWebKitWaiterLocked(waiter)
204- delete(webkitCallbacks.waiters, plan.success)201+ }, nil
202+}
203+
204+func findWebKitWaiterLocked(name, requestID string) *webkitCallbackWaiter {
205+ waiters := webkitCallbacks.waiters[name]
206+ if len(waiters) == 0 {
207+ return nil
208+ }
209+ if requestID == "" {
210+ return waiters[0]
211+ }
212+ for _, waiter := range waiters {
213+ if waiter != nil && waiter.requestID == requestID {
214+ return waiter
205 }215 }
206- if webkitCallbacks.waiters[plan.failure] == waiter {216+ }
207- delete(webkitCallbacks.waiters, plan.failure)217+ return nil
218+}
219+
220+func removeWebKitWaiterLocked(waiter *webkitCallbackWaiter) {
221+ if waiter == nil {
222+ return
223+ }
224+ remove := func(name string) {
225+ waiters := webkitCallbacks.waiters[name]
226+ for i, candidate := range waiters {
227+ if candidate != waiter {
228+ continue
229+ }
230+ waiters = append(waiters[:i], waiters[i+1:]...)
231+ if len(waiters) == 0 {
232+ delete(webkitCallbacks.waiters, name)
233+ } else {
234+ webkitCallbacks.waiters[name] = waiters
235+ }
236+ return
208 }237 }
209- }, nil238+ }
239+ remove(waiter.successName)
240+ remove(waiter.failureName)
210 }241 }
211 242
212 func handleWebKitCallback(name string, failure bool, args []js.Value) {243 func handleWebKitCallback(name string, failure bool, args []js.Value) {
@@ -214,15 +245,9 @@ func handleWebKitCallback(name string, failure bool, args []js.Value) {
214 payloadRequestID := requestIDFromBridgePayload(payload)245 payloadRequestID := requestIDFromBridgePayload(payload)
215 246
216 webkitCallbacks.Lock()247 webkitCallbacks.Lock()
217- waiter := webkitCallbacks.waiters[name]248+ waiter := findWebKitWaiterLocked(name, payloadRequestID)
218- if waiter != nil && payloadRequestID != "" && waiter.requestID != "" && payloadRequestID != waiter.requestID {
219- webkitCallbacks.Unlock()
220- warnDroppedBridgeResult("webkit callback request mismatch", fmt.Errorf("got %s, want %s", payloadRequestID, waiter.requestID))
221- return
222- }
223 if waiter != nil {249 if waiter != nil {
224- delete(webkitCallbacks.waiters, waiter.successName)250+ removeWebKitWaiterLocked(waiter)
225- delete(webkitCallbacks.waiters, waiter.failureName)
226 }251 }
227 webkitCallbacks.Unlock()252 webkitCallbacks.Unlock()
228 253
M internal/infrastructure/systemviewsbridge/client.go +4 -4
@@ -237,11 +237,11 @@ func (c *Client) List(ctx context.Context) ([]*entity.Favorite, error) {
237237 }{RequestID: nextRequestID()})
238238 }
239239
240-func (c *Client) CreateFavorite(ctx context.Context, input port.FavoriteCreateInput) (*entity.Favorite, error) {
240+func (c *Client) CreateFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
241241 return request[*entity.Favorite](c, ctx, "favorite_create", favoriteCreatePayload(input))
242242 }
243243
244-func (c *Client) UpdateFavorite(ctx context.Context, input port.FavoriteUpdateInput) (*entity.Favorite, error) {
244+func (c *Client) UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) {
245245 if input.ID <= 0 {
246246 return nil, fmt.Errorf("update favorite id must be positive, got %d", input.ID)
247247 }
@@ -259,7 +259,7 @@ func (c *Client) DeleteFavorite(ctx context.Context, id int64) error {
259259 return err
260260 }
261261
262-func favoriteCreatePayload(input port.FavoriteCreateInput) any {
262+func favoriteCreatePayload(input dto.FavoriteCreateInput) any {
263263 return struct {
264264 RequestID string `json:"requestId"`
265265 URL string `json:"url"`
@@ -277,7 +277,7 @@ func favoriteCreatePayload(input port.FavoriteCreateInput) any {
277277 }
278278 }
279279
280-func favoriteUpdatePayload(input port.FavoriteUpdateInput) any {
280+func favoriteUpdatePayload(input dto.FavoriteUpdateInput) any {
281281 return struct {
282282 RequestID string `json:"requestId"`
283283 ID int64 `json:"id"`
@@ -237,11 +237,11 @@ func (c *Client) List(ctx context.Context) ([]*entity.Favorite, error) {
237 }{RequestID: nextRequestID()})237 }{RequestID: nextRequestID()})
238 }238 }
239 239
240-func (c *Client) CreateFavorite(ctx context.Context, input port.FavoriteCreateInput) (*entity.Favorite, error) {240+func (c *Client) CreateFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
241 return request[*entity.Favorite](c, ctx, "favorite_create", favoriteCreatePayload(input))241 return request[*entity.Favorite](c, ctx, "favorite_create", favoriteCreatePayload(input))
242 }242 }
243 243
244-func (c *Client) UpdateFavorite(ctx context.Context, input port.FavoriteUpdateInput) (*entity.Favorite, error) {244+func (c *Client) UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) {
245 if input.ID <= 0 {245 if input.ID <= 0 {
246 return nil, fmt.Errorf("update favorite id must be positive, got %d", input.ID)246 return nil, fmt.Errorf("update favorite id must be positive, got %d", input.ID)
247 }247 }
@@ -259,7 +259,7 @@ func (c *Client) DeleteFavorite(ctx context.Context, id int64) error {
259 return err259 return err
260 }260 }
261 261
262-func favoriteCreatePayload(input port.FavoriteCreateInput) any {262+func favoriteCreatePayload(input dto.FavoriteCreateInput) any {
263 return struct {263 return struct {
264 RequestID string `json:"requestId"`264 RequestID string `json:"requestId"`
265 URL string `json:"url"`265 URL string `json:"url"`
@@ -277,7 +277,7 @@ func favoriteCreatePayload(input port.FavoriteCreateInput) any {
277 }277 }
278 }278 }
279 279
280-func favoriteUpdatePayload(input port.FavoriteUpdateInput) any {280+func favoriteUpdatePayload(input dto.FavoriteUpdateInput) any {
281 return struct {281 return struct {
282 RequestID string `json:"requestId"`282 RequestID string `json:"requestId"`
283 ID int64 `json:"id"`283 ID int64 `json:"id"`
M internal/infrastructure/systemviewsbridge/client_test.go +7 -7
@@ -310,13 +310,13 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) {
310310
311311 tests := []struct {
312312 name string
313- call func(*Client) error
313+ call func(*testing.T, *Client) error
314314 want string
315315 payload any
316316 }{
317317 {
318318 name: "save config",
319- call: func(client *Client) error {
319+ call: func(t *testing.T, client *Client) error {
320320 return client.Save(context.Background(), dto.WebUIConfig{DefaultSearchEngine: "DuckDuckGo"})
321321 },
322322 want: "save_config",
@@ -324,7 +324,7 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) {
324324 },
325325 {
326326 name: "get keybindings",
327- call: func(client *Client) error {
327+ call: func(t *testing.T, client *Client) error {
328328 got, err := client.GetKeybindings(context.Background())
329329 if err != nil {
330330 return err
@@ -338,7 +338,7 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) {
338338 },
339339 {
340340 name: "set keybinding",
341- call: func(client *Client) error {
341+ call: func(t *testing.T, client *Client) error {
342342 got, err := client.SetKeybinding(context.Background(), port.SetKeybindingRequest{RequestID: "req-1", Mode: "default", Action: "open", Keys: []string{"ctrl+o"}})
343343 if err != nil {
344344 return err
@@ -353,7 +353,7 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) {
353353 },
354354 {
355355 name: "reset keybinding",
356- call: func(client *Client) error {
356+ call: func(t *testing.T, client *Client) error {
357357 return client.ResetKeybinding(context.Background(), port.ResetKeybindingRequest{RequestID: "req-2", Mode: "default", Action: "open"})
358358 },
359359 want: "reset_keybinding",
@@ -361,7 +361,7 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) {
361361 },
362362 {
363363 name: "reset all keybindings",
364- call: func(client *Client) error {
364+ call: func(t *testing.T, client *Client) error {
365365 return client.ResetAllKeybindings(context.Background())
366366 },
367367 want: "reset_all_keybindings",
@@ -381,7 +381,7 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) {
381381 }
382382 client := NewClient(transport, nil)
383383
384- if err := tt.call(client); err != nil {
384+ if err := tt.call(t, client); err != nil {
385385 t.Fatalf("call() error = %v", err)
386386 }
387387
@@ -310,13 +310,13 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) {
310 310
311 tests := []struct {311 tests := []struct {
312 name string312 name string
313- call func(*Client) error313+ call func(*testing.T, *Client) error
314 want string314 want string
315 payload any315 payload any
316 }{316 }{
317 {317 {
318 name: "save config",318 name: "save config",
319- call: func(client *Client) error {319+ call: func(t *testing.T, client *Client) error {
320 return client.Save(context.Background(), dto.WebUIConfig{DefaultSearchEngine: "DuckDuckGo"})320 return client.Save(context.Background(), dto.WebUIConfig{DefaultSearchEngine: "DuckDuckGo"})
321 },321 },
322 want: "save_config",322 want: "save_config",
@@ -324,7 +324,7 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) {
324 },324 },
325 {325 {
326 name: "get keybindings",326 name: "get keybindings",
327- call: func(client *Client) error {327+ call: func(t *testing.T, client *Client) error {
328 got, err := client.GetKeybindings(context.Background())328 got, err := client.GetKeybindings(context.Background())
329 if err != nil {329 if err != nil {
330 return err330 return err
@@ -338,7 +338,7 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) {
338 },338 },
339 {339 {
340 name: "set keybinding",340 name: "set keybinding",
341- call: func(client *Client) error {341+ call: func(t *testing.T, client *Client) error {
342 got, err := client.SetKeybinding(context.Background(), port.SetKeybindingRequest{RequestID: "req-1", Mode: "default", Action: "open", Keys: []string{"ctrl+o"}})342 got, err := client.SetKeybinding(context.Background(), port.SetKeybindingRequest{RequestID: "req-1", Mode: "default", Action: "open", Keys: []string{"ctrl+o"}})
343 if err != nil {343 if err != nil {
344 return err344 return err
@@ -353,7 +353,7 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) {
353 },353 },
354 {354 {
355 name: "reset keybinding",355 name: "reset keybinding",
356- call: func(client *Client) error {356+ call: func(t *testing.T, client *Client) error {
357 return client.ResetKeybinding(context.Background(), port.ResetKeybindingRequest{RequestID: "req-2", Mode: "default", Action: "open"})357 return client.ResetKeybinding(context.Background(), port.ResetKeybindingRequest{RequestID: "req-2", Mode: "default", Action: "open"})
358 },358 },
359 want: "reset_keybinding",359 want: "reset_keybinding",
@@ -361,7 +361,7 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) {
361 },361 },
362 {362 {
363 name: "reset all keybindings",363 name: "reset all keybindings",
364- call: func(client *Client) error {364+ call: func(t *testing.T, client *Client) error {
365 return client.ResetAllKeybindings(context.Background())365 return client.ResetAllKeybindings(context.Background())
366 },366 },
367 want: "reset_all_keybindings",367 want: "reset_all_keybindings",
@@ -381,7 +381,7 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) {
381 }381 }
382 client := NewClient(transport, nil)382 client := NewClient(transport, nil)
383 383
384- if err := tt.call(client); err != nil {384+ if err := tt.call(t, client); err != nil {
385 t.Fatalf("call() error = %v", err)385 t.Fatalf("call() error = %v", err)
386 }386 }
387 387
M internal/ui/systemviews/app.go +144 -37
@@ -53,6 +53,8 @@ type App struct {
5353 configNotice string
5454 configError string
5555 renderedHTML string
56+ renderGeneration uint64
57+ closed bool
5658 actionQueue chan DOMAction
5759 actionErrorQueue chan error
5860 actionCtx context.Context
@@ -133,18 +135,35 @@ func (a *App) refreshCurrentRouteAsync(ctx context.Context) {
133135 if a == nil {
134136 return
135137 }
138+ a.lockState()
139+ snapshot := a.currentHistoryRouteSnapshotLocked()
140+ generation := a.renderGeneration
141+ closed := a.closed
142+ a.unlockState()
143+ if closed || snapshot.route != RouteHistory {
144+ return
145+ }
136146 go func() {
147+ result, err := a.renderHistoryRouteSnapshot(ctx, snapshot)
148+
137149 a.lockState()
138- defer a.unlockState()
139- if err := a.loadCurrentRoute(ctx); err != nil {
150+ if a.closed || ctx.Err() != nil || a.currentRoute != snapshot.route || a.renderGeneration != generation {
151+ a.unlockState()
152+ return
153+ }
154+ if err != nil {
140155 a.renderRouteError(err)
156+ } else {
157+ a.commitHistoryRouteResultLocked(result)
141158 }
159+ a.renderGeneration++
160+ mountGeneration := a.renderGeneration
142161 html := a.renderedHTML
143- go func() {
144- if err := a.mountHTML(html); err != nil {
145- logActionMountError(ctx, err, nil)
146- }
147- }()
162+ a.unlockState()
163+
164+ if err := a.mountHTMLIfCurrent(ctx, html, mountGeneration); err != nil {
165+ logActionMountError(ctx, err, nil)
166+ }
148167 }()
149168 }
150169
@@ -324,9 +343,11 @@ func (a *App) surfaceActionError(ctx context.Context, err error) {
324343 }
325344 a.lockState()
326345 a.renderRouteError(err)
346+ a.renderGeneration++
347+ generation := a.renderGeneration
327348 html := a.renderedHTML
328349 a.unlockState()
329- if mountErr := a.mountHTML(html); mountErr != nil {
350+ if mountErr := a.mountHTMLIfCurrent(ctx, html, generation); mountErr != nil {
330351 logActionMountError(ctx, mountErr, err)
331352 }
332353 }
@@ -335,6 +356,11 @@ func (a *App) Close() {
335356 if a == nil {
336357 return
337358 }
359+ a.lockState()
360+ a.closed = true
361+ a.renderGeneration++
362+ a.unlockState()
363+
338364 a.lockAction()
339365 cancel := a.actionCancel
340366 actionQueue := a.actionQueue
@@ -507,56 +533,137 @@ func (a *App) loadHistoryRoute(ctx context.Context) error {
507533 return nil
508534 }
509535
510- window, entries, err := a.loadHistoryWindow(ctx)
536+ result, err := a.renderHistoryRouteSnapshot(ctx, a.currentHistoryRouteSnapshotLocked())
511537 if err != nil {
512538 return err
513539 }
514- stats, err := a.deps.History.Stats(ctx)
540+ a.commitHistoryRouteResultLocked(result)
541+ return nil
542+}
543+
544+type historyRouteSnapshot struct {
545+ route Route
546+ history port.SystemviewHistoryService
547+ shellTheme shellTheme
548+ query string
549+ domainFilter string
550+ offset int
551+ windowBefore time.Time
552+ notice string
553+ error string
554+}
555+
556+type historyRouteResult struct {
557+ entries []*entity.HistoryEntry
558+ stats *entity.HistoryStats
559+ domains []*entity.DomainStat
560+ windowBefore time.Time
561+ windowAfter time.Time
562+ hasMore bool
563+ html string
564+}
565+
566+func (a *App) currentHistoryRouteSnapshotLocked() historyRouteSnapshot {
567+ return historyRouteSnapshot{
568+ route: a.currentRoute,
569+ history: a.deps.History,
570+ shellTheme: a.shellTheme,
571+ query: a.historyQuery,
572+ domainFilter: a.historyDomainFilter,
573+ offset: a.historyOffset,
574+ windowBefore: a.historyWindowBefore,
575+ notice: a.historyNotice,
576+ error: a.historyError,
577+ }
578+}
579+
580+func (a *App) renderHistoryRouteSnapshot(ctx context.Context, snapshot historyRouteSnapshot) (historyRouteResult, error) {
581+ if snapshot.history == nil {
582+ return historyRouteResult{
583+ html: renderAppFrame(renderedPage{
584+ route: snapshot.route,
585+ title: routeDocumentTitle(RouteHistory),
586+ subtitle: "Recent visits",
587+ body: placeholderHTML(snapshot.route),
588+ }, snapshot.shellTheme),
589+ }, nil
590+ }
591+
592+ window, entries, err := loadHistoryWindowSnapshot(ctx, snapshot)
515593 if err != nil {
516- return fmt.Errorf("failed to load history stats: %w", err)
594+ return historyRouteResult{}, err
517595 }
518- domains, domainsErr := a.deps.History.DomainStats(ctx, 10)
596+ stats, err := snapshot.history.Stats(ctx)
597+ if err != nil {
598+ return historyRouteResult{}, fmt.Errorf("failed to load history stats: %w", err)
599+ }
600+ domains, domainsErr := snapshot.history.DomainStats(ctx, 10)
519601 if domainsErr != nil {
520602 domains = nil
521603 }
522- a.favorites = nil
523- a.folders = nil
524- a.tags = nil
525- a.historyEntries = entries
526- a.historyStats = stats
527- a.historyAnalytics = nil
528- a.historyDomainStats = domains
604+ result := historyRouteResult{
605+ entries: entries,
606+ stats: stats,
607+ domains: domains,
608+ }
529609 if window != nil {
530- a.historyWindowBefore = window.Before
531- a.historyWindowAfter = window.After
532- a.historyHasMore = window.HasMore
533- } else {
534- a.historyWindowBefore = time.Time{}
535- a.historyWindowAfter = time.Time{}
536- a.historyHasMore = false
610+ result.windowBefore = window.Before
611+ result.windowAfter = window.After
612+ result.hasMore = window.HasMore
537613 }
538614 data := historyRenderData{
539615 Entries: entries,
540616 Stats: stats,
541617 Analytics: nil,
542618 Domains: domains,
543- Query: a.historyQuery,
544- DomainFilter: a.historyDomainFilter,
545- Offset: a.historyOffset,
619+ Query: snapshot.query,
620+ DomainFilter: snapshot.domainFilter,
621+ Offset: snapshot.offset,
546622 Limit: historyTimelineLimit,
547- WindowBefore: a.historyWindowBefore,
548- WindowAfter: a.historyWindowAfter,
549- HasMore: a.historyHasMore,
550- Notice: a.historyNotice,
551- Error: a.historyError,
623+ WindowBefore: result.windowBefore,
624+ WindowAfter: result.windowAfter,
625+ HasMore: result.hasMore,
626+ Notice: snapshot.notice,
627+ Error: snapshot.error,
552628 }
553- a.renderedHTML = renderAppFrame(renderedPage{
629+ result.html = renderAppFrame(renderedPage{
554630 route: RouteHistory,
555631 title: historyDocumentTitle(data),
556632 subtitle: "Recent visits",
557633 body: historyHTML(data),
558- }, a.shellTheme)
559- return nil
634+ }, snapshot.shellTheme)
635+ return result, nil
636+}
637+
638+func loadHistoryWindowSnapshot(ctx context.Context, snapshot historyRouteSnapshot) (*entity.HistoryWindow, []*entity.HistoryEntry, error) {
639+ query := strings.TrimSpace(snapshot.query)
640+ domain := strings.TrimSpace(snapshot.domainFilter)
641+ if query != "" {
642+ entries, err := snapshot.history.Search(ctx, query, historySearchLimit)
643+ return nil, entries, err
644+ }
645+ window, err := snapshot.history.TimelineWindow(ctx, snapshot.windowBefore, domain)
646+ if err != nil {
647+ return nil, nil, err
648+ }
649+ if window == nil {
650+ return nil, nil, nil
651+ }
652+ return window, window.Entries, nil
653+}
654+
655+func (a *App) commitHistoryRouteResultLocked(result historyRouteResult) {
656+ a.favorites = nil
657+ a.folders = nil
658+ a.tags = nil
659+ a.historyEntries = result.entries
660+ a.historyStats = result.stats
661+ a.historyAnalytics = nil
662+ a.historyDomainStats = result.domains
663+ a.historyWindowBefore = result.windowBefore
664+ a.historyWindowAfter = result.windowAfter
665+ a.historyHasMore = result.hasMore
666+ a.renderedHTML = result.html
560667 }
561668
562669 func (a *App) loadHistoryWindow(ctx context.Context) (*entity.HistoryWindow, []*entity.HistoryEntry, error) {
@@ -53,6 +53,8 @@ type App struct {
53 configNotice string53 configNotice string
54 configError string54 configError string
55 renderedHTML string55 renderedHTML string
56+ renderGeneration uint64
57+ closed bool
56 actionQueue chan DOMAction58 actionQueue chan DOMAction
57 actionErrorQueue chan error59 actionErrorQueue chan error
58 actionCtx context.Context60 actionCtx context.Context
@@ -133,18 +135,35 @@ func (a *App) refreshCurrentRouteAsync(ctx context.Context) {
133 if a == nil {135 if a == nil {
134 return136 return
135 }137 }
138+ a.lockState()
139+ snapshot := a.currentHistoryRouteSnapshotLocked()
140+ generation := a.renderGeneration
141+ closed := a.closed
142+ a.unlockState()
143+ if closed || snapshot.route != RouteHistory {
144+ return
145+ }
136 go func() {146 go func() {
147+ result, err := a.renderHistoryRouteSnapshot(ctx, snapshot)
148+
137 a.lockState()149 a.lockState()
138- defer a.unlockState()150+ if a.closed || ctx.Err() != nil || a.currentRoute != snapshot.route || a.renderGeneration != generation {
139- if err := a.loadCurrentRoute(ctx); err != nil {151+ a.unlockState()
152+ return
153+ }
154+ if err != nil {
140 a.renderRouteError(err)155 a.renderRouteError(err)
156+ } else {
157+ a.commitHistoryRouteResultLocked(result)
141 }158 }
159+ a.renderGeneration++
160+ mountGeneration := a.renderGeneration
142 html := a.renderedHTML161 html := a.renderedHTML
143- go func() {162+ a.unlockState()
144- if err := a.mountHTML(html); err != nil {163+
145- logActionMountError(ctx, err, nil)164+ if err := a.mountHTMLIfCurrent(ctx, html, mountGeneration); err != nil {
146- }165+ logActionMountError(ctx, err, nil)
147- }()166+ }
148 }()167 }()
149 }168 }
150 169
@@ -324,9 +343,11 @@ func (a *App) surfaceActionError(ctx context.Context, err error) {
324 }343 }
325 a.lockState()344 a.lockState()
326 a.renderRouteError(err)345 a.renderRouteError(err)
346+ a.renderGeneration++
347+ generation := a.renderGeneration
327 html := a.renderedHTML348 html := a.renderedHTML
328 a.unlockState()349 a.unlockState()
329- if mountErr := a.mountHTML(html); mountErr != nil {350+ if mountErr := a.mountHTMLIfCurrent(ctx, html, generation); mountErr != nil {
330 logActionMountError(ctx, mountErr, err)351 logActionMountError(ctx, mountErr, err)
331 }352 }
332 }353 }
@@ -335,6 +356,11 @@ func (a *App) Close() {
335 if a == nil {356 if a == nil {
336 return357 return
337 }358 }
359+ a.lockState()
360+ a.closed = true
361+ a.renderGeneration++
362+ a.unlockState()
363+
338 a.lockAction()364 a.lockAction()
339 cancel := a.actionCancel365 cancel := a.actionCancel
340 actionQueue := a.actionQueue366 actionQueue := a.actionQueue
@@ -507,56 +533,137 @@ func (a *App) loadHistoryRoute(ctx context.Context) error {
507 return nil533 return nil
508 }534 }
509 535
510- window, entries, err := a.loadHistoryWindow(ctx)536+ result, err := a.renderHistoryRouteSnapshot(ctx, a.currentHistoryRouteSnapshotLocked())
511 if err != nil {537 if err != nil {
512 return err538 return err
513 }539 }
514- stats, err := a.deps.History.Stats(ctx)540+ a.commitHistoryRouteResultLocked(result)
541+ return nil
542+}
543+
544+type historyRouteSnapshot struct {
545+ route Route
546+ history port.SystemviewHistoryService
547+ shellTheme shellTheme
548+ query string
549+ domainFilter string
550+ offset int
551+ windowBefore time.Time
552+ notice string
553+ error string
554+}
555+
556+type historyRouteResult struct {
557+ entries []*entity.HistoryEntry
558+ stats *entity.HistoryStats
559+ domains []*entity.DomainStat
560+ windowBefore time.Time
561+ windowAfter time.Time
562+ hasMore bool
563+ html string
564+}
565+
566+func (a *App) currentHistoryRouteSnapshotLocked() historyRouteSnapshot {
567+ return historyRouteSnapshot{
568+ route: a.currentRoute,
569+ history: a.deps.History,
570+ shellTheme: a.shellTheme,
571+ query: a.historyQuery,
572+ domainFilter: a.historyDomainFilter,
573+ offset: a.historyOffset,
574+ windowBefore: a.historyWindowBefore,
575+ notice: a.historyNotice,
576+ error: a.historyError,
577+ }
578+}
579+
580+func (a *App) renderHistoryRouteSnapshot(ctx context.Context, snapshot historyRouteSnapshot) (historyRouteResult, error) {
581+ if snapshot.history == nil {
582+ return historyRouteResult{
583+ html: renderAppFrame(renderedPage{
584+ route: snapshot.route,
585+ title: routeDocumentTitle(RouteHistory),
586+ subtitle: "Recent visits",
587+ body: placeholderHTML(snapshot.route),
588+ }, snapshot.shellTheme),
589+ }, nil
590+ }
591+
592+ window, entries, err := loadHistoryWindowSnapshot(ctx, snapshot)
515 if err != nil {593 if err != nil {
516- return fmt.Errorf("failed to load history stats: %w", err)594+ return historyRouteResult{}, err
517 }595 }
518- domains, domainsErr := a.deps.History.DomainStats(ctx, 10)596+ stats, err := snapshot.history.Stats(ctx)
597+ if err != nil {
598+ return historyRouteResult{}, fmt.Errorf("failed to load history stats: %w", err)
599+ }
600+ domains, domainsErr := snapshot.history.DomainStats(ctx, 10)
519 if domainsErr != nil {601 if domainsErr != nil {
520 domains = nil602 domains = nil
521 }603 }
522- a.favorites = nil604+ result := historyRouteResult{
523- a.folders = nil605+ entries: entries,
524- a.tags = nil606+ stats: stats,
525- a.historyEntries = entries607+ domains: domains,
526- a.historyStats = stats608+ }
527- a.historyAnalytics = nil
528- a.historyDomainStats = domains
529 if window != nil {609 if window != nil {
530- a.historyWindowBefore = window.Before610+ result.windowBefore = window.Before
531- a.historyWindowAfter = window.After611+ result.windowAfter = window.After
532- a.historyHasMore = window.HasMore612+ result.hasMore = window.HasMore
533- } else {
534- a.historyWindowBefore = time.Time{}
535- a.historyWindowAfter = time.Time{}
536- a.historyHasMore = false
537 }613 }
538 data := historyRenderData{614 data := historyRenderData{
539 Entries: entries,615 Entries: entries,
540 Stats: stats,616 Stats: stats,
541 Analytics: nil,617 Analytics: nil,
542 Domains: domains,618 Domains: domains,
543- Query: a.historyQuery,619+ Query: snapshot.query,
544- DomainFilter: a.historyDomainFilter,620+ DomainFilter: snapshot.domainFilter,
545- Offset: a.historyOffset,621+ Offset: snapshot.offset,
546 Limit: historyTimelineLimit,622 Limit: historyTimelineLimit,
547- WindowBefore: a.historyWindowBefore,623+ WindowBefore: result.windowBefore,
548- WindowAfter: a.historyWindowAfter,624+ WindowAfter: result.windowAfter,
549- HasMore: a.historyHasMore,625+ HasMore: result.hasMore,
550- Notice: a.historyNotice,626+ Notice: snapshot.notice,
551- Error: a.historyError,627+ Error: snapshot.error,
552 }628 }
553- a.renderedHTML = renderAppFrame(renderedPage{629+ result.html = renderAppFrame(renderedPage{
554 route: RouteHistory,630 route: RouteHistory,
555 title: historyDocumentTitle(data),631 title: historyDocumentTitle(data),
556 subtitle: "Recent visits",632 subtitle: "Recent visits",
557 body: historyHTML(data),633 body: historyHTML(data),
558- }, a.shellTheme)634+ }, snapshot.shellTheme)
559- return nil635+ return result, nil
636+}
637+
638+func loadHistoryWindowSnapshot(ctx context.Context, snapshot historyRouteSnapshot) (*entity.HistoryWindow, []*entity.HistoryEntry, error) {
639+ query := strings.TrimSpace(snapshot.query)
640+ domain := strings.TrimSpace(snapshot.domainFilter)
641+ if query != "" {
642+ entries, err := snapshot.history.Search(ctx, query, historySearchLimit)
643+ return nil, entries, err
644+ }
645+ window, err := snapshot.history.TimelineWindow(ctx, snapshot.windowBefore, domain)
646+ if err != nil {
647+ return nil, nil, err
648+ }
649+ if window == nil {
650+ return nil, nil, nil
651+ }
652+ return window, window.Entries, nil
653+}
654+
655+func (a *App) commitHistoryRouteResultLocked(result historyRouteResult) {
656+ a.favorites = nil
657+ a.folders = nil
658+ a.tags = nil
659+ a.historyEntries = result.entries
660+ a.historyStats = result.stats
661+ a.historyAnalytics = nil
662+ a.historyDomainStats = result.domains
663+ a.historyWindowBefore = result.windowBefore
664+ a.historyWindowAfter = result.windowAfter
665+ a.historyHasMore = result.hasMore
666+ a.renderedHTML = result.html
560 }667 }
561 668
562 func (a *App) loadHistoryWindow(ctx context.Context) (*entity.HistoryWindow, []*entity.HistoryEntry, error) {669 func (a *App) loadHistoryWindow(ctx context.Context) (*entity.HistoryWindow, []*entity.HistoryEntry, error) {
M internal/ui/systemviews/app_test.go +67 -4
@@ -144,6 +144,60 @@ func TestAppRunMountsHistoryLoadingBeforeAsyncHydration(t *testing.T) {
144144 assert.Contains(t, hydratedHTML, "Loaded 1 item")
145145 }
146146
147+func TestSurfaceActionErrorPreventsStaleAsyncHydrationMount(t *testing.T) {
148+ t.Parallel()
149+
150+ dom := &fakeDOM{mounts: make(chan string, 4)}
151+ app := NewApp(Dependencies{DOM: dom, LocationURI: "dumb://history"})
152+ app.currentRoute = RouteHistory
153+ staleHTML := "<div>Stale hydrated entry</div>"
154+ staleGeneration := app.renderGeneration
155+ ctx := context.Background()
156+
157+ app.surfaceActionError(ctx, errors.New("action worker failed"))
158+ errorHTML := receiveMount(t, dom.mounts)
159+ assert.Contains(t, errorHTML, "action worker failed")
160+ assert.NotContains(t, errorHTML, "Stale hydrated entry")
161+
162+ require.NoError(t, app.mountHTMLIfCurrent(ctx, staleHTML, staleGeneration))
163+ assertNoMount(t, dom.mounts, 100*time.Millisecond)
164+ assert.Contains(t, dom.html, "action worker failed")
165+ assert.NotContains(t, dom.html, "Stale hydrated entry")
166+}
167+
168+func TestSurfaceActionErrorWhileAsyncHydrationBlockedPreventsStaleMount(t *testing.T) {
169+ t.Parallel()
170+
171+ dom := &fakeActionDOM{fakeDOM: fakeDOM{mounts: make(chan string, 4)}}
172+ history := &fakeHistoryService{
173+ entries: []*entity.HistoryEntry{{ID: 1, URL: "https://example.com", Title: "Stale hydrated entry"}},
174+ timelineStarted: make(chan struct{}),
175+ releaseTimeline: make(chan struct{}),
176+ }
177+ app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
178+ ctx := context.Background()
179+
180+ require.NoError(t, app.RunWithContext(ctx))
181+ loadingHTML := receiveMount(t, dom.mounts)
182+ assert.Contains(t, loadingHTML, "Loading history")
183+
184+ select {
185+ case <-history.timelineStarted:
186+ case <-time.After(time.Second):
187+ t.Fatal("history timeline was not started asynchronously")
188+ }
189+
190+ app.surfaceActionError(ctx, errors.New("action worker failed"))
191+ errorHTML := receiveMount(t, dom.mounts)
192+ assert.Contains(t, errorHTML, "action worker failed")
193+ assert.NotContains(t, errorHTML, "Stale hydrated entry")
194+
195+ close(history.releaseTimeline)
196+ assertNoMount(t, dom.mounts, 100*time.Millisecond)
197+ assert.Contains(t, dom.html, "action worker failed")
198+ assert.NotContains(t, dom.html, "Stale hydrated entry")
199+}
200+
147201 func TestAppHandleHistoryLoadMoreAppendsOlderWindow(t *testing.T) {
148202 t.Parallel()
149203
@@ -879,6 +933,15 @@ func receiveMount(t *testing.T, mounts <-chan string) string {
879933 }
880934 }
881935
936+func assertNoMount(t *testing.T, mounts <-chan string, wait time.Duration) {
937+ t.Helper()
938+ select {
939+ case html := <-mounts:
940+ t.Fatalf("unexpected DOM mount: %s", html)
941+ case <-time.After(wait):
942+ }
943+}
944+
882945 type fakeActionDOM struct {
883946 fakeDOM
884947 handler DOMActionHandler
@@ -1029,8 +1092,8 @@ type fakeFavoritesService struct {
10291092 folders []*entity.Folder
10301093 tags []*entity.Tag
10311094
1032- createdFavorite port.FavoriteCreateInput
1033- updatedFavorite port.FavoriteUpdateInput
1095+ createdFavorite dto.FavoriteCreateInput
1096+ updatedFavorite dto.FavoriteUpdateInput
10341097 deletedFavorite int64
10351098 createdFolder string
10361099 createdFolderIcon string
@@ -1060,12 +1123,12 @@ func (s *fakeFavoritesService) ListTags(context.Context) ([]*entity.Tag, error)
10601123 return s.tags, nil
10611124 }
10621125
1063-func (s *fakeFavoritesService) CreateFavorite(_ context.Context, input port.FavoriteCreateInput) (*entity.Favorite, error) {
1126+func (s *fakeFavoritesService) CreateFavorite(_ context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
10641127 s.createdFavorite = input
10651128 return &entity.Favorite{ID: 99, URL: input.URL, Title: input.Title, FolderID: input.FolderID}, nil
10661129 }
10671130
1068-func (s *fakeFavoritesService) UpdateFavorite(_ context.Context, input port.FavoriteUpdateInput) (*entity.Favorite, error) {
1131+func (s *fakeFavoritesService) UpdateFavorite(_ context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) {
10691132 s.updatedFavorite = input
10701133 return &entity.Favorite{ID: input.ID, URL: "https://example.com", Title: input.Title, FaviconURL: input.FaviconURL, FolderID: input.FolderID, ShortcutKey: input.ShortcutKey}, nil
10711134 }
@@ -144,6 +144,60 @@ func TestAppRunMountsHistoryLoadingBeforeAsyncHydration(t *testing.T) {
144 assert.Contains(t, hydratedHTML, "Loaded 1 item")144 assert.Contains(t, hydratedHTML, "Loaded 1 item")
145 }145 }
146 146
147+func TestSurfaceActionErrorPreventsStaleAsyncHydrationMount(t *testing.T) {
148+ t.Parallel()
149+
150+ dom := &fakeDOM{mounts: make(chan string, 4)}
151+ app := NewApp(Dependencies{DOM: dom, LocationURI: "dumb://history"})
152+ app.currentRoute = RouteHistory
153+ staleHTML := "<div>Stale hydrated entry</div>"
154+ staleGeneration := app.renderGeneration
155+ ctx := context.Background()
156+
157+ app.surfaceActionError(ctx, errors.New("action worker failed"))
158+ errorHTML := receiveMount(t, dom.mounts)
159+ assert.Contains(t, errorHTML, "action worker failed")
160+ assert.NotContains(t, errorHTML, "Stale hydrated entry")
161+
162+ require.NoError(t, app.mountHTMLIfCurrent(ctx, staleHTML, staleGeneration))
163+ assertNoMount(t, dom.mounts, 100*time.Millisecond)
164+ assert.Contains(t, dom.html, "action worker failed")
165+ assert.NotContains(t, dom.html, "Stale hydrated entry")
166+}
167+
168+func TestSurfaceActionErrorWhileAsyncHydrationBlockedPreventsStaleMount(t *testing.T) {
169+ t.Parallel()
170+
171+ dom := &fakeActionDOM{fakeDOM: fakeDOM{mounts: make(chan string, 4)}}
172+ history := &fakeHistoryService{
173+ entries: []*entity.HistoryEntry{{ID: 1, URL: "https://example.com", Title: "Stale hydrated entry"}},
174+ timelineStarted: make(chan struct{}),
175+ releaseTimeline: make(chan struct{}),
176+ }
177+ app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
178+ ctx := context.Background()
179+
180+ require.NoError(t, app.RunWithContext(ctx))
181+ loadingHTML := receiveMount(t, dom.mounts)
182+ assert.Contains(t, loadingHTML, "Loading history")
183+
184+ select {
185+ case <-history.timelineStarted:
186+ case <-time.After(time.Second):
187+ t.Fatal("history timeline was not started asynchronously")
188+ }
189+
190+ app.surfaceActionError(ctx, errors.New("action worker failed"))
191+ errorHTML := receiveMount(t, dom.mounts)
192+ assert.Contains(t, errorHTML, "action worker failed")
193+ assert.NotContains(t, errorHTML, "Stale hydrated entry")
194+
195+ close(history.releaseTimeline)
196+ assertNoMount(t, dom.mounts, 100*time.Millisecond)
197+ assert.Contains(t, dom.html, "action worker failed")
198+ assert.NotContains(t, dom.html, "Stale hydrated entry")
199+}
200+
147 func TestAppHandleHistoryLoadMoreAppendsOlderWindow(t *testing.T) {201 func TestAppHandleHistoryLoadMoreAppendsOlderWindow(t *testing.T) {
148 t.Parallel()202 t.Parallel()
149 203
@@ -879,6 +933,15 @@ func receiveMount(t *testing.T, mounts <-chan string) string {
879 }933 }
880 }934 }
881 935
936+func assertNoMount(t *testing.T, mounts <-chan string, wait time.Duration) {
937+ t.Helper()
938+ select {
939+ case html := <-mounts:
940+ t.Fatalf("unexpected DOM mount: %s", html)
941+ case <-time.After(wait):
942+ }
943+}
944+
882 type fakeActionDOM struct {945 type fakeActionDOM struct {
883 fakeDOM946 fakeDOM
884 handler DOMActionHandler947 handler DOMActionHandler
@@ -1029,8 +1092,8 @@ type fakeFavoritesService struct {
1029 folders []*entity.Folder1092 folders []*entity.Folder
1030 tags []*entity.Tag1093 tags []*entity.Tag
1031 1094
1032- createdFavorite port.FavoriteCreateInput1095+ createdFavorite dto.FavoriteCreateInput
1033- updatedFavorite port.FavoriteUpdateInput1096+ updatedFavorite dto.FavoriteUpdateInput
1034 deletedFavorite int641097 deletedFavorite int64
1035 createdFolder string1098 createdFolder string
1036 createdFolderIcon string1099 createdFolderIcon string
@@ -1060,12 +1123,12 @@ func (s *fakeFavoritesService) ListTags(context.Context) ([]*entity.Tag, error)
1060 return s.tags, nil1123 return s.tags, nil
1061 }1124 }
1062 1125
1063-func (s *fakeFavoritesService) CreateFavorite(_ context.Context, input port.FavoriteCreateInput) (*entity.Favorite, error) {1126+func (s *fakeFavoritesService) CreateFavorite(_ context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
1064 s.createdFavorite = input1127 s.createdFavorite = input
1065 return &entity.Favorite{ID: 99, URL: input.URL, Title: input.Title, FolderID: input.FolderID}, nil1128 return &entity.Favorite{ID: 99, URL: input.URL, Title: input.Title, FolderID: input.FolderID}, nil
1066 }1129 }
1067 1130
1068-func (s *fakeFavoritesService) UpdateFavorite(_ context.Context, input port.FavoriteUpdateInput) (*entity.Favorite, error) {1131+func (s *fakeFavoritesService) UpdateFavorite(_ context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) {
1069 s.updatedFavorite = input1132 s.updatedFavorite = input
1070 return &entity.Favorite{ID: input.ID, URL: "https://example.com", Title: input.Title, FaviconURL: input.FaviconURL, FolderID: input.FolderID, ShortcutKey: input.ShortcutKey}, nil1133 return &entity.Favorite{ID: input.ID, URL: "https://example.com", Title: input.Title, FaviconURL: input.FaviconURL, FolderID: input.FolderID, ShortcutKey: input.ShortcutKey}, nil
1071 }1134 }
M internal/ui/systemviews/config.templ +7 -7
@@ -102,19 +102,19 @@ templ SearchShortcutItem(shortcut searchShortcutRow) {
102102
103103 templ PerformanceSettings(config dto.SystemviewConfigPayload) {
104104 {{ customProfile := performanceProfileSelected(config.Performance.Profile, "custom") }}
105- <form class="sv-card-form sv-config-form" data-sv-action="config.performance.save">
106- <label><span>Profile</span><select name="profile">
105+ <form class="sv-card-form sv-config-form" data-sv-action="config.performance.save" data-sv-performance-form>
106+ <label><span>Profile</span><select name="profile" data-sv-performance-profile>
107107 <option value="default" selected?={ performanceProfileSelected(config.Performance.Profile, "default") }>Default</option>
108108 <option value="lite" selected?={ performanceProfileSelected(config.Performance.Profile, "lite") }>Lite</option>
109109 <option value="max" selected?={ performanceProfileSelected(config.Performance.Profile, "max") }>Max</option>
110110 <option value="custom" selected?={ performanceProfileSelected(config.Performance.Profile, "custom") }>Custom</option>
111111 </select></label>
112112 <p class="sv-meta">Choose Custom to edit individual performance values.</p>
113- <label><span>Skia CPU threads</span><input name="skia_cpu_threads" type="number" min="0" max="8" value={ formatConfigInt(config.Performance.Custom.SkiaCPUThreads) } disabled?={ !customProfile }/></label>
114- <label><span>Skia GPU threads</span><input name="skia_gpu_threads" type="number" min="-1" max="8" value={ formatConfigInt(config.Performance.Custom.SkiaGPUThreads) } disabled?={ !customProfile }/></label>
115- <label><span>Web process memory MB</span><input name="web_process_memory_mb" type="number" min="0" max="16384" value={ formatConfigInt(config.Performance.Custom.WebProcessMemoryMB) } disabled?={ !customProfile }/></label>
116- <label><span>Network process memory MB</span><input name="network_process_memory_mb" type="number" min="0" max="4096" value={ formatConfigInt(config.Performance.Custom.NetworkProcessMemoryMB) } disabled?={ !customProfile }/></label>
117- <label><span>WebView pool prewarm</span><input name="webview_pool_prewarm" type="number" min="0" max="20" value={ formatConfigInt(config.Performance.Custom.WebViewPoolPrewarm) } disabled?={ !customProfile }/></label>
113+ <label><span>Skia CPU threads</span><input name="skia_cpu_threads" type="number" min="0" max="8" value={ formatConfigInt(config.Performance.Custom.SkiaCPUThreads) } disabled?={ !customProfile } data-sv-performance-custom/></label>
114+ <label><span>Skia GPU threads</span><input name="skia_gpu_threads" type="number" min="-1" max="8" value={ formatConfigInt(config.Performance.Custom.SkiaGPUThreads) } disabled?={ !customProfile } data-sv-performance-custom/></label>
115+ <label><span>Web process memory MB</span><input name="web_process_memory_mb" type="number" min="0" max="16384" value={ formatConfigInt(config.Performance.Custom.WebProcessMemoryMB) } disabled?={ !customProfile } data-sv-performance-custom/></label>
116+ <label><span>Network process memory MB</span><input name="network_process_memory_mb" type="number" min="0" max="4096" value={ formatConfigInt(config.Performance.Custom.NetworkProcessMemoryMB) } disabled?={ !customProfile } data-sv-performance-custom/></label>
117+ <label><span>WebView pool prewarm</span><input name="webview_pool_prewarm" type="number" min="0" max="20" value={ formatConfigInt(config.Performance.Custom.WebViewPoolPrewarm) } disabled?={ !customProfile } data-sv-performance-custom/></label>
118118 <button class="sv-button" type="submit">@ButtonIconLabel(iconSave, "Save performance")</button>
119119 </form>
120120 <div class="sv-stat-grid">
@@ -102,19 +102,19 @@ templ SearchShortcutItem(shortcut searchShortcutRow) {
102 102
103 templ PerformanceSettings(config dto.SystemviewConfigPayload) {103 templ PerformanceSettings(config dto.SystemviewConfigPayload) {
104 {{ customProfile := performanceProfileSelected(config.Performance.Profile, "custom") }}104 {{ customProfile := performanceProfileSelected(config.Performance.Profile, "custom") }}
105- <form class="sv-card-form sv-config-form" data-sv-action="config.performance.save">105+ <form class="sv-card-form sv-config-form" data-sv-action="config.performance.save" data-sv-performance-form>
106- <label><span>Profile</span><select name="profile">106+ <label><span>Profile</span><select name="profile" data-sv-performance-profile>
107 <option value="default" selected?={ performanceProfileSelected(config.Performance.Profile, "default") }>Default</option>107 <option value="default" selected?={ performanceProfileSelected(config.Performance.Profile, "default") }>Default</option>
108 <option value="lite" selected?={ performanceProfileSelected(config.Performance.Profile, "lite") }>Lite</option>108 <option value="lite" selected?={ performanceProfileSelected(config.Performance.Profile, "lite") }>Lite</option>
109 <option value="max" selected?={ performanceProfileSelected(config.Performance.Profile, "max") }>Max</option>109 <option value="max" selected?={ performanceProfileSelected(config.Performance.Profile, "max") }>Max</option>
110 <option value="custom" selected?={ performanceProfileSelected(config.Performance.Profile, "custom") }>Custom</option>110 <option value="custom" selected?={ performanceProfileSelected(config.Performance.Profile, "custom") }>Custom</option>
111 </select></label>111 </select></label>
112 <p class="sv-meta">Choose Custom to edit individual performance values.</p>112 <p class="sv-meta">Choose Custom to edit individual performance values.</p>
113- <label><span>Skia CPU threads</span><input name="skia_cpu_threads" type="number" min="0" max="8" value={ formatConfigInt(config.Performance.Custom.SkiaCPUThreads) } disabled?={ !customProfile }/></label>113+ <label><span>Skia CPU threads</span><input name="skia_cpu_threads" type="number" min="0" max="8" value={ formatConfigInt(config.Performance.Custom.SkiaCPUThreads) } disabled?={ !customProfile } data-sv-performance-custom/></label>
114- <label><span>Skia GPU threads</span><input name="skia_gpu_threads" type="number" min="-1" max="8" value={ formatConfigInt(config.Performance.Custom.SkiaGPUThreads) } disabled?={ !customProfile }/></label>114+ <label><span>Skia GPU threads</span><input name="skia_gpu_threads" type="number" min="-1" max="8" value={ formatConfigInt(config.Performance.Custom.SkiaGPUThreads) } disabled?={ !customProfile } data-sv-performance-custom/></label>
115- <label><span>Web process memory MB</span><input name="web_process_memory_mb" type="number" min="0" max="16384" value={ formatConfigInt(config.Performance.Custom.WebProcessMemoryMB) } disabled?={ !customProfile }/></label>115+ <label><span>Web process memory MB</span><input name="web_process_memory_mb" type="number" min="0" max="16384" value={ formatConfigInt(config.Performance.Custom.WebProcessMemoryMB) } disabled?={ !customProfile } data-sv-performance-custom/></label>
116- <label><span>Network process memory MB</span><input name="network_process_memory_mb" type="number" min="0" max="4096" value={ formatConfigInt(config.Performance.Custom.NetworkProcessMemoryMB) } disabled?={ !customProfile }/></label>116+ <label><span>Network process memory MB</span><input name="network_process_memory_mb" type="number" min="0" max="4096" value={ formatConfigInt(config.Performance.Custom.NetworkProcessMemoryMB) } disabled?={ !customProfile } data-sv-performance-custom/></label>
117- <label><span>WebView pool prewarm</span><input name="webview_pool_prewarm" type="number" min="0" max="20" value={ formatConfigInt(config.Performance.Custom.WebViewPoolPrewarm) } disabled?={ !customProfile }/></label>117+ <label><span>WebView pool prewarm</span><input name="webview_pool_prewarm" type="number" min="0" max="20" value={ formatConfigInt(config.Performance.Custom.WebViewPoolPrewarm) } disabled?={ !customProfile } data-sv-performance-custom/></label>
118 <button class="sv-button" type="submit">@ButtonIconLabel(iconSave, "Save performance")</button>118 <button class="sv-button" type="submit">@ButtonIconLabel(iconSave, "Save performance")</button>
119 </form>119 </form>
120 <div class="sv-stat-grid">120 <div class="sv-stat-grid">
M internal/ui/systemviews/config_templ.go +6 -6 generated file: collapsed
M internal/ui/systemviews/dom_js.go +55 -3
@@ -211,6 +211,7 @@ func (d *browserDOM) BindActions(handler DOMActionHandler) error {
211211 return nil
212212 }
213213 event.Call("preventDefault")
214+ updatePerformanceCustomInputs(form)
214215 data := collectActionData(form)
215216 for key, value := range collectFormData(form) {
216217 data[key] = value
@@ -222,6 +223,23 @@ func (d *browserDOM) BindActions(handler DOMActionHandler) error {
222223 })
223224 d.addEventBinding(d.target, "submit", submitHandler)
224225
226+ changeHandler := js.FuncOf(func(_ js.Value, args []js.Value) any {
227+ event := firstJSArg(args)
228+ if !event.Truthy() {
229+ return nil
230+ }
231+ target := event.Get("target")
232+ if !target.Truthy() || !target.Get("matches").Truthy() || !target.Call("matches", "[data-sv-performance-profile]").Bool() {
233+ return nil
234+ }
235+ form := target.Get("form")
236+ if form.Truthy() {
237+ updatePerformanceCustomInputs(form)
238+ }
239+ return nil
240+ })
241+ d.addEventBinding(d.target, "change", changeHandler)
242+
225243 keydownHandler := js.FuncOf(func(_ js.Value, args []js.Value) any {
226244 event := firstJSArg(args)
227245 if !d.eventTargetInsideMount(event) {
@@ -372,6 +390,21 @@ func collectActionData(element js.Value) map[string]string {
372390 return data
373391 }
374392
393+func updatePerformanceCustomInputs(form js.Value) {
394+ if !form.Truthy() || !form.Get("querySelector").Truthy() {
395+ return
396+ }
397+ selectEl := form.Call("querySelector", "[data-sv-performance-profile]")
398+ if !selectEl.Truthy() {
399+ return
400+ }
401+ enabled := strings.EqualFold(selectEl.Get("value").String(), "custom")
402+ inputs := form.Call("querySelectorAll", "[data-sv-performance-custom]")
403+ for i := 0; i < inputs.Get("length").Int(); i++ {
404+ inputs.Index(i).Set("disabled", !enabled)
405+ }
406+}
407+
375408 func collectFormData(form js.Value) map[string]string {
376409 data := map[string]string{}
377410 if !form.Truthy() || !js.Global().Get("FormData").Truthy() {
@@ -529,9 +562,11 @@ func (d *browserDOM) showConfirmDialog(message string, onConfirm func()) bool {
529562 event.Call("stopPropagation")
530563 cleanup()
531564 case "enter":
532- event.Call("preventDefault")
533- event.Call("stopPropagation")
534- runConfirm()
565+ if confirmDialogEnterShouldAutoConfirm(d.document, dialog, confirmButton) {
566+ event.Call("preventDefault")
567+ event.Call("stopPropagation")
568+ runConfirm()
569+ }
535570 }
536571 return nil
537572 })
@@ -554,6 +589,23 @@ func (d *browserDOM) showConfirmDialog(message string, onConfirm func()) bool {
554589 return true
555590 }
556591
592+func confirmDialogEnterShouldAutoConfirm(document, dialog, confirmButton js.Value) bool {
593+ if !document.Truthy() || !dialog.Truthy() || !confirmButton.Truthy() {
594+ return false
595+ }
596+ active := document.Get("activeElement")
597+ if !active.Truthy() {
598+ return true
599+ }
600+ if active.Equal(confirmButton) {
601+ return true
602+ }
603+ if dialog.Call("contains", active).Bool() {
604+ return false
605+ }
606+ return true
607+}
608+
557609 func (d *browserDOM) handleKeyboard(event js.Value) {
558610 if d == nil || !d.target.Truthy() || !event.Truthy() {
559611 return
@@ -211,6 +211,7 @@ func (d *browserDOM) BindActions(handler DOMActionHandler) error {
211 return nil211 return nil
212 }212 }
213 event.Call("preventDefault")213 event.Call("preventDefault")
214+ updatePerformanceCustomInputs(form)
214 data := collectActionData(form)215 data := collectActionData(form)
215 for key, value := range collectFormData(form) {216 for key, value := range collectFormData(form) {
216 data[key] = value217 data[key] = value
@@ -222,6 +223,23 @@ func (d *browserDOM) BindActions(handler DOMActionHandler) error {
222 })223 })
223 d.addEventBinding(d.target, "submit", submitHandler)224 d.addEventBinding(d.target, "submit", submitHandler)
224 225
226+ changeHandler := js.FuncOf(func(_ js.Value, args []js.Value) any {
227+ event := firstJSArg(args)
228+ if !event.Truthy() {
229+ return nil
230+ }
231+ target := event.Get("target")
232+ if !target.Truthy() || !target.Get("matches").Truthy() || !target.Call("matches", "[data-sv-performance-profile]").Bool() {
233+ return nil
234+ }
235+ form := target.Get("form")
236+ if form.Truthy() {
237+ updatePerformanceCustomInputs(form)
238+ }
239+ return nil
240+ })
241+ d.addEventBinding(d.target, "change", changeHandler)
242+
225 keydownHandler := js.FuncOf(func(_ js.Value, args []js.Value) any {243 keydownHandler := js.FuncOf(func(_ js.Value, args []js.Value) any {
226 event := firstJSArg(args)244 event := firstJSArg(args)
227 if !d.eventTargetInsideMount(event) {245 if !d.eventTargetInsideMount(event) {
@@ -372,6 +390,21 @@ func collectActionData(element js.Value) map[string]string {
372 return data390 return data
373 }391 }
374 392
393+func updatePerformanceCustomInputs(form js.Value) {
394+ if !form.Truthy() || !form.Get("querySelector").Truthy() {
395+ return
396+ }
397+ selectEl := form.Call("querySelector", "[data-sv-performance-profile]")
398+ if !selectEl.Truthy() {
399+ return
400+ }
401+ enabled := strings.EqualFold(selectEl.Get("value").String(), "custom")
402+ inputs := form.Call("querySelectorAll", "[data-sv-performance-custom]")
403+ for i := 0; i < inputs.Get("length").Int(); i++ {
404+ inputs.Index(i).Set("disabled", !enabled)
405+ }
406+}
407+
375 func collectFormData(form js.Value) map[string]string {408 func collectFormData(form js.Value) map[string]string {
376 data := map[string]string{}409 data := map[string]string{}
377 if !form.Truthy() || !js.Global().Get("FormData").Truthy() {410 if !form.Truthy() || !js.Global().Get("FormData").Truthy() {
@@ -529,9 +562,11 @@ func (d *browserDOM) showConfirmDialog(message string, onConfirm func()) bool {
529 event.Call("stopPropagation")562 event.Call("stopPropagation")
530 cleanup()563 cleanup()
531 case "enter":564 case "enter":
532- event.Call("preventDefault")565+ if confirmDialogEnterShouldAutoConfirm(d.document, dialog, confirmButton) {
533- event.Call("stopPropagation")566+ event.Call("preventDefault")
534- runConfirm()567+ event.Call("stopPropagation")
568+ runConfirm()
569+ }
535 }570 }
536 return nil571 return nil
537 })572 })
@@ -554,6 +589,23 @@ func (d *browserDOM) showConfirmDialog(message string, onConfirm func()) bool {
554 return true589 return true
555 }590 }
556 591
592+func confirmDialogEnterShouldAutoConfirm(document, dialog, confirmButton js.Value) bool {
593+ if !document.Truthy() || !dialog.Truthy() || !confirmButton.Truthy() {
594+ return false
595+ }
596+ active := document.Get("activeElement")
597+ if !active.Truthy() {
598+ return true
599+ }
600+ if active.Equal(confirmButton) {
601+ return true
602+ }
603+ if dialog.Call("contains", active).Bool() {
604+ return false
605+ }
606+ return true
607+}
608+
557 func (d *browserDOM) handleKeyboard(event js.Value) {609 func (d *browserDOM) handleKeyboard(event js.Value) {
558 if d == nil || !d.target.Truthy() || !event.Truthy() {610 if d == nil || !d.target.Truthy() || !event.Truthy() {
559 return611 return
M internal/ui/systemviews/favorite_actions.go +3 -3
@@ -7,7 +7,7 @@ import (
77 "strconv"
88 "strings"
99
10- "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/application/dto"
1111 "github.com/bnema/dumber/internal/domain/entity"
1212 )
1313
@@ -47,7 +47,7 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {
4747 if err != nil {
4848 return err
4949 }
50- favorite, err := a.deps.Favorites.CreateFavorite(ctx, port.FavoriteCreateInput{
50+ favorite, err := a.deps.Favorites.CreateFavorite(ctx, dto.FavoriteCreateInput{
5151 URL: favoriteURL,
5252 Title: strings.TrimSpace(data["title"]),
5353 FolderID: folderID,
@@ -70,7 +70,7 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {
7070 if err != nil {
7171 return err
7272 }
73- favorite, err := a.deps.Favorites.UpdateFavorite(ctx, port.FavoriteUpdateInput{
73+ favorite, err := a.deps.Favorites.UpdateFavorite(ctx, dto.FavoriteUpdateInput{
7474 ID: entity.FavoriteID(id),
7575 Title: strings.TrimSpace(data["title"]),
7676 FaviconURL: strings.TrimSpace(data["favicon_url"]),
@@ -7,7 +7,7 @@ import (
7 "strconv"7 "strconv"
8 "strings"8 "strings"
9 9
10- "github.com/bnema/dumber/internal/application/port"10+ "github.com/bnema/dumber/internal/application/dto"
11 "github.com/bnema/dumber/internal/domain/entity"11 "github.com/bnema/dumber/internal/domain/entity"
12 )12 )
13 13
@@ -47,7 +47,7 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {
47 if err != nil {47 if err != nil {
48 return err48 return err
49 }49 }
50- favorite, err := a.deps.Favorites.CreateFavorite(ctx, port.FavoriteCreateInput{50+ favorite, err := a.deps.Favorites.CreateFavorite(ctx, dto.FavoriteCreateInput{
51 URL: favoriteURL,51 URL: favoriteURL,
52 Title: strings.TrimSpace(data["title"]),52 Title: strings.TrimSpace(data["title"]),
53 FolderID: folderID,53 FolderID: folderID,
@@ -70,7 +70,7 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {
70 if err != nil {70 if err != nil {
71 return err71 return err
72 }72 }
73- favorite, err := a.deps.Favorites.UpdateFavorite(ctx, port.FavoriteUpdateInput{73+ favorite, err := a.deps.Favorites.UpdateFavorite(ctx, dto.FavoriteUpdateInput{
74 ID: entity.FavoriteID(id),74 ID: entity.FavoriteID(id),
75 Title: strings.TrimSpace(data["title"]),75 Title: strings.TrimSpace(data["title"]),
76 FaviconURL: strings.TrimSpace(data["favicon_url"]),76 FaviconURL: strings.TrimSpace(data["favicon_url"]),
M internal/ui/systemviews/history_actions.go +22 -1
@@ -28,6 +28,11 @@ func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error {
2828 return fmt.Errorf("app is nil")
2929 }
3030 a.lockState()
31+ if a.closed {
32+ a.unlockState()
33+ return nil
34+ }
35+ a.renderGeneration++
3136 if a.currentRoute == RouteUnknown || a.currentRoute == "" {
3237 a.currentRoute = ParseRoute(a.deps.LocationURI)
3338 }
@@ -257,7 +262,23 @@ func (a *App) mountRenderedHTML() error {
257262 }
258263
259264 func (a *App) mountHTML(html string) error {
260- if a.deps.DOM == nil {
265+ if a == nil || a.deps.DOM == nil {
266+ return nil
267+ }
268+ return a.deps.DOM.Mount(html)
269+}
270+
271+func (a *App) mountHTMLIfCurrent(ctx context.Context, html string, generation uint64) error {
272+ if a == nil || a.deps.DOM == nil {
273+ return nil
274+ }
275+ if ctx != nil && ctx.Err() != nil {
276+ return nil
277+ }
278+ a.lockState()
279+ current := !a.closed && a.renderGeneration == generation
280+ a.unlockState()
281+ if !current {
261282 return nil
262283 }
263284 return a.deps.DOM.Mount(html)
@@ -28,6 +28,11 @@ func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error {
28 return fmt.Errorf("app is nil")28 return fmt.Errorf("app is nil")
29 }29 }
30 a.lockState()30 a.lockState()
31+ if a.closed {
32+ a.unlockState()
33+ return nil
34+ }
35+ a.renderGeneration++
31 if a.currentRoute == RouteUnknown || a.currentRoute == "" {36 if a.currentRoute == RouteUnknown || a.currentRoute == "" {
32 a.currentRoute = ParseRoute(a.deps.LocationURI)37 a.currentRoute = ParseRoute(a.deps.LocationURI)
33 }38 }
@@ -257,7 +262,23 @@ func (a *App) mountRenderedHTML() error {
257 }262 }
258 263
259 func (a *App) mountHTML(html string) error {264 func (a *App) mountHTML(html string) error {
260- if a.deps.DOM == nil {265+ if a == nil || a.deps.DOM == nil {
266+ return nil
267+ }
268+ return a.deps.DOM.Mount(html)
269+}
270+
271+func (a *App) mountHTMLIfCurrent(ctx context.Context, html string, generation uint64) error {
272+ if a == nil || a.deps.DOM == nil {
273+ return nil
274+ }
275+ if ctx != nil && ctx.Err() != nil {
276+ return nil
277+ }
278+ a.lockState()
279+ current := !a.closed && a.renderGeneration == generation
280+ a.unlockState()
281+ if !current {
261 return nil282 return nil
262 }283 }
263 return a.deps.DOM.Mount(html)284 return a.deps.DOM.Mount(html)

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