feat(omnibox): persist and toggle default history order
Let the omnibox switch its empty-open history source between recent and most used without leaving the UI. This persists the preference through config, keeps the layers clean, and locks the behavior in with focused tests.
- commit
- 32b1250d6f634762f7d3d75066f7db1f6b269ce4
- author
- brice · · 2026-04-12 04:10 UTC
- parent
- 48a7b55
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
19 files · +782 -95
M .mockery.yaml +1 -0
| @@ -29,6 +29,7 @@ packages: | |||
| 29 | 29 | FontDetector: {} | |
| 30 | 30 | KeybindingsProvider: {} | |
| 31 | 31 | KeybindingsSaver: {} | |
| 32 | + | OmniboxPreferencesSaver: {} | |
| 32 | 33 | DownloadResponse: {} | |
| 33 | 34 | PermissionDialogPresenter: {} | |
| 34 | 35 | PermissionRepository: {} | |
| @@ -29,6 +29,7 @@ packages: | |||||
| 29 | FontDetector: {} | 29 | FontDetector: {} | ||
| 30 | KeybindingsProvider: {} | 30 | KeybindingsProvider: {} | ||
| 31 | KeybindingsSaver: {} | 31 | KeybindingsSaver: {} | ||
| 32 | + | OmniboxPreferencesSaver: {} | |||
| 32 | DownloadResponse: {} | 33 | DownloadResponse: {} | ||
| 33 | PermissionDialogPresenter: {} | 34 | PermissionDialogPresenter: {} | ||
| 34 | PermissionRepository: {} | 35 | PermissionRepository: {} | ||
M internal/application/port/handler_deps.go +11 -6
| @@ -1,6 +1,10 @@ | |||
| 1 | 1 | package port | |
| 2 | 2 | ||
| 3 | - | import "context" | |
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 7 | + | ) | |
| 4 | 8 | ||
| 5 | 9 | // AccentKeyHandler receives accent/dead-key events from the WebView JS bridge. | |
| 6 | 10 | type AccentKeyHandler interface { | |
| @@ -39,9 +43,10 @@ type AutoCopyConfig interface { | |||
| 39 | 43 | // A struct is used here to avoid reverse dependencies between the bootstrap and | |
| 40 | 44 | // UI layers, which both need to reference this shared dependency container. | |
| 41 | 45 | type HandlerDeps struct { | |
| 42 | - | SaveConfig func(context.Context, WebUIConfig) error | |
| 43 | - | KeybindingsGetter KeybindingsGetter | |
| 44 | - | KeybindingSetter KeybindingSetter | |
| 45 | - | KeybindingResetter KeybindingResetter | |
| 46 | - | AllKeybindingsResetter AllKeybindingsResetter | |
| 46 | + | SaveConfig func(context.Context, WebUIConfig) error | |
| 47 | + | SaveOmniboxInitialBehavior func(context.Context, entity.OmniboxInitialBehavior) error | |
| 48 | + | KeybindingsGetter KeybindingsGetter | |
| 49 | + | KeybindingSetter KeybindingSetter | |
| 50 | + | KeybindingResetter KeybindingResetter | |
| 51 | + | AllKeybindingsResetter AllKeybindingsResetter | |
| 47 | 52 | } | |
| @@ -1,6 +1,10 @@ | |||||
| 1 | package port | 1 | package port | ||
| 2 | 2 | ||||
| 3 | - | import "context" | 3 | + | import ( |
| 4 | + | "context" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 7 | + | ) | |||
| 4 | 8 | ||||
| 5 | // AccentKeyHandler receives accent/dead-key events from the WebView JS bridge. | 9 | // AccentKeyHandler receives accent/dead-key events from the WebView JS bridge. | ||
| 6 | type AccentKeyHandler interface { | 10 | type AccentKeyHandler interface { | ||
| @@ -39,9 +43,10 @@ type AutoCopyConfig interface { | |||||
| 39 | // A struct is used here to avoid reverse dependencies between the bootstrap and | 43 | // A struct is used here to avoid reverse dependencies between the bootstrap and | ||
| 40 | // UI layers, which both need to reference this shared dependency container. | 44 | // UI layers, which both need to reference this shared dependency container. | ||
| 41 | type HandlerDeps struct { | 45 | type HandlerDeps struct { | ||
| 42 | - | SaveConfig func(context.Context, WebUIConfig) error | 46 | + | SaveConfig func(context.Context, WebUIConfig) error |
| 43 | - | KeybindingsGetter KeybindingsGetter | 47 | + | SaveOmniboxInitialBehavior func(context.Context, entity.OmniboxInitialBehavior) error |
| 44 | - | KeybindingSetter KeybindingSetter | 48 | + | KeybindingsGetter KeybindingsGetter |
| 45 | - | KeybindingResetter KeybindingResetter | 49 | + | KeybindingSetter KeybindingSetter |
| 46 | - | AllKeybindingsResetter AllKeybindingsResetter | 50 | + | KeybindingResetter KeybindingResetter |
| 51 | + | AllKeybindingsResetter AllKeybindingsResetter | |||
| 47 | } | 52 | } | ||
M internal/application/port/mocks/mocks.go +84 -0
| @@ -8320,6 +8320,90 @@ func (_c *MockNotification_ShowZoom_Call) RunAndReturn(run func(ctx context.Cont | |||
| 8320 | 8320 | return _c | |
| 8321 | 8321 | } | |
| 8322 | 8322 | ||
| 8323 | + | // NewMockOmniboxPreferencesSaver creates a new instance of MockOmniboxPreferencesSaver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 8324 | + | // The first argument is typically a *testing.T value. | |
| 8325 | + | func NewMockOmniboxPreferencesSaver(t interface { | |
| 8326 | + | mock.TestingT | |
| 8327 | + | Cleanup(func()) | |
| 8328 | + | }) *MockOmniboxPreferencesSaver { | |
| 8329 | + | mock := &MockOmniboxPreferencesSaver{} | |
| 8330 | + | mock.Mock.Test(t) | |
| 8331 | + | ||
| 8332 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 8333 | + | ||
| 8334 | + | return mock | |
| 8335 | + | } | |
| 8336 | + | ||
| 8337 | + | // MockOmniboxPreferencesSaver is an autogenerated mock type for the OmniboxPreferencesSaver type | |
| 8338 | + | type MockOmniboxPreferencesSaver struct { | |
| 8339 | + | mock.Mock | |
| 8340 | + | } | |
| 8341 | + | ||
| 8342 | + | type MockOmniboxPreferencesSaver_Expecter struct { | |
| 8343 | + | mock *mock.Mock | |
| 8344 | + | } | |
| 8345 | + | ||
| 8346 | + | func (_m *MockOmniboxPreferencesSaver) EXPECT() *MockOmniboxPreferencesSaver_Expecter { | |
| 8347 | + | return &MockOmniboxPreferencesSaver_Expecter{mock: &_m.Mock} | |
| 8348 | + | } | |
| 8349 | + | ||
| 8350 | + | // SaveOmniboxInitialBehavior provides a mock function for the type MockOmniboxPreferencesSaver | |
| 8351 | + | func (_mock *MockOmniboxPreferencesSaver) SaveOmniboxInitialBehavior(ctx context.Context, behavior entity.OmniboxInitialBehavior) error { | |
| 8352 | + | ret := _mock.Called(ctx, behavior) | |
| 8353 | + | ||
| 8354 | + | if len(ret) == 0 { | |
| 8355 | + | panic("no return value specified for SaveOmniboxInitialBehavior") | |
| 8356 | + | } | |
| 8357 | + | ||
| 8358 | + | var r0 error | |
| 8359 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, entity.OmniboxInitialBehavior) error); ok { | |
| 8360 | + | r0 = returnFunc(ctx, behavior) | |
| 8361 | + | } else { | |
| 8362 | + | r0 = ret.Error(0) | |
| 8363 | + | } | |
| 8364 | + | return r0 | |
| 8365 | + | } | |
| 8366 | + | ||
| 8367 | + | // MockOmniboxPreferencesSaver_SaveOmniboxInitialBehavior_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveOmniboxInitialBehavior' | |
| 8368 | + | type MockOmniboxPreferencesSaver_SaveOmniboxInitialBehavior_Call struct { | |
| 8369 | + | *mock.Call | |
| 8370 | + | } | |
| 8371 | + | ||
| 8372 | + | // SaveOmniboxInitialBehavior is a helper method to define mock.On call | |
| 8373 | + | // - ctx context.Context | |
| 8374 | + | // - behavior entity.OmniboxInitialBehavior | |
| 8375 | + | func (_e *MockOmniboxPreferencesSaver_Expecter) SaveOmniboxInitialBehavior(ctx interface{}, behavior interface{}) *MockOmniboxPreferencesSaver_SaveOmniboxInitialBehavior_Call { | |
| 8376 | + | return &MockOmniboxPreferencesSaver_SaveOmniboxInitialBehavior_Call{Call: _e.mock.On("SaveOmniboxInitialBehavior", ctx, behavior)} | |
| 8377 | + | } | |
| 8378 | + | ||
| 8379 | + | func (_c *MockOmniboxPreferencesSaver_SaveOmniboxInitialBehavior_Call) Run(run func(ctx context.Context, behavior entity.OmniboxInitialBehavior)) *MockOmniboxPreferencesSaver_SaveOmniboxInitialBehavior_Call { | |
| 8380 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 8381 | + | var arg0 context.Context | |
| 8382 | + | if args[0] != nil { | |
| 8383 | + | arg0 = args[0].(context.Context) | |
| 8384 | + | } | |
| 8385 | + | var arg1 entity.OmniboxInitialBehavior | |
| 8386 | + | if args[1] != nil { | |
| 8387 | + | arg1 = args[1].(entity.OmniboxInitialBehavior) | |
| 8388 | + | } | |
| 8389 | + | run( | |
| 8390 | + | arg0, | |
| 8391 | + | arg1, | |
| 8392 | + | ) | |
| 8393 | + | }) | |
| 8394 | + | return _c | |
| 8395 | + | } | |
| 8396 | + | ||
| 8397 | + | func (_c *MockOmniboxPreferencesSaver_SaveOmniboxInitialBehavior_Call) Return(err error) *MockOmniboxPreferencesSaver_SaveOmniboxInitialBehavior_Call { | |
| 8398 | + | _c.Call.Return(err) | |
| 8399 | + | return _c | |
| 8400 | + | } | |
| 8401 | + | ||
| 8402 | + | func (_c *MockOmniboxPreferencesSaver_SaveOmniboxInitialBehavior_Call) RunAndReturn(run func(ctx context.Context, behavior entity.OmniboxInitialBehavior) error) *MockOmniboxPreferencesSaver_SaveOmniboxInitialBehavior_Call { | |
| 8403 | + | _c.Call.Return(run) | |
| 8404 | + | return _c | |
| 8405 | + | } | |
| 8406 | + | ||
| 8323 | 8407 | // NewMockPermissionDialogPresenter creates a new instance of MockPermissionDialogPresenter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 8324 | 8408 | // The first argument is typically a *testing.T value. | |
| 8325 | 8409 | func NewMockPermissionDialogPresenter(t interface { | |
| @@ -8320,6 +8320,90 @@ func (_c *MockNotification_ShowZoom_Call) RunAndReturn(run func(ctx context.Cont | |||||
| 8320 | return _c | 8320 | return _c | ||
| 8321 | } | 8321 | } | ||
| 8322 | 8322 | ||||
| 8323 | + | // NewMockOmniboxPreferencesSaver creates a new instance of MockOmniboxPreferencesSaver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 8324 | + | // The first argument is typically a *testing.T value. | |||
| 8325 | + | func NewMockOmniboxPreferencesSaver(t interface { | |||
| 8326 | + | mock.TestingT | |||
| 8327 | + | Cleanup(func()) | |||
| 8328 | + | }) *MockOmniboxPreferencesSaver { | |||
| 8329 | + | mock := &MockOmniboxPreferencesSaver{} | |||
| 8330 | + | mock.Mock.Test(t) | |||
| 8331 | + | ||||
| 8332 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 8333 | + | ||||
| 8334 | + | return mock | |||
| 8335 | + | } | |||
| 8336 | + | ||||
| 8337 | + | // MockOmniboxPreferencesSaver is an autogenerated mock type for the OmniboxPreferencesSaver type | |||
| 8338 | + | type MockOmniboxPreferencesSaver struct { | |||
| 8339 | + | mock.Mock | |||
| 8340 | + | } | |||
| 8341 | + | ||||
| 8342 | + | type MockOmniboxPreferencesSaver_Expecter struct { | |||
| 8343 | + | mock *mock.Mock | |||
| 8344 | + | } | |||
| 8345 | + | ||||
| 8346 | + | func (_m *MockOmniboxPreferencesSaver) EXPECT() *MockOmniboxPreferencesSaver_Expecter { | |||
| 8347 | + | return &MockOmniboxPreferencesSaver_Expecter{mock: &_m.Mock} | |||
| 8348 | + | } | |||
| 8349 | + | ||||
| 8350 | + | // SaveOmniboxInitialBehavior provides a mock function for the type MockOmniboxPreferencesSaver | |||
| 8351 | + | func (_mock *MockOmniboxPreferencesSaver) SaveOmniboxInitialBehavior(ctx context.Context, behavior entity.OmniboxInitialBehavior) error { | |||
| 8352 | + | ret := _mock.Called(ctx, behavior) | |||
| 8353 | + | ||||
| 8354 | + | if len(ret) == 0 { | |||
| 8355 | + | panic("no return value specified for SaveOmniboxInitialBehavior") | |||
| 8356 | + | } | |||
| 8357 | + | ||||
| 8358 | + | var r0 error | |||
| 8359 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, entity.OmniboxInitialBehavior) error); ok { | |||
| 8360 | + | r0 = returnFunc(ctx, behavior) | |||
| 8361 | + | } else { | |||
| 8362 | + | r0 = ret.Error(0) | |||
| 8363 | + | } | |||
| 8364 | + | return r0 | |||
| 8365 | + | } | |||
| 8366 | + | ||||
| 8367 | + | // MockOmniboxPreferencesSaver_SaveOmniboxInitialBehavior_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveOmniboxInitialBehavior' | |||
| 8368 | + | type MockOmniboxPreferencesSaver_SaveOmniboxInitialBehavior_Call struct { | |||
| 8369 | + | *mock.Call | |||
| 8370 | + | } | |||
| 8371 | + | ||||
| 8372 | + | // SaveOmniboxInitialBehavior is a helper method to define mock.On call | |||
| 8373 | + | // - ctx context.Context | |||
| 8374 | + | // - behavior entity.OmniboxInitialBehavior | |||
| 8375 | + | func (_e *MockOmniboxPreferencesSaver_Expecter) SaveOmniboxInitialBehavior(ctx interface{}, behavior interface{}) *MockOmniboxPreferencesSaver_SaveOmniboxInitialBehavior_Call { | |||
| 8376 | + | return &MockOmniboxPreferencesSaver_SaveOmniboxInitialBehavior_Call{Call: _e.mock.On("SaveOmniboxInitialBehavior", ctx, behavior)} | |||
| 8377 | + | } | |||
| 8378 | + | ||||
| 8379 | + | func (_c *MockOmniboxPreferencesSaver_SaveOmniboxInitialBehavior_Call) Run(run func(ctx context.Context, behavior entity.OmniboxInitialBehavior)) *MockOmniboxPreferencesSaver_SaveOmniboxInitialBehavior_Call { | |||
| 8380 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 8381 | + | var arg0 context.Context | |||
| 8382 | + | if args[0] != nil { | |||
| 8383 | + | arg0 = args[0].(context.Context) | |||
| 8384 | + | } | |||
| 8385 | + | var arg1 entity.OmniboxInitialBehavior | |||
| 8386 | + | if args[1] != nil { | |||
| 8387 | + | arg1 = args[1].(entity.OmniboxInitialBehavior) | |||
| 8388 | + | } | |||
| 8389 | + | run( | |||
| 8390 | + | arg0, | |||
| 8391 | + | arg1, | |||
| 8392 | + | ) | |||
| 8393 | + | }) | |||
| 8394 | + | return _c | |||
| 8395 | + | } | |||
| 8396 | + | ||||
| 8397 | + | func (_c *MockOmniboxPreferencesSaver_SaveOmniboxInitialBehavior_Call) Return(err error) *MockOmniboxPreferencesSaver_SaveOmniboxInitialBehavior_Call { | |||
| 8398 | + | _c.Call.Return(err) | |||
| 8399 | + | return _c | |||
| 8400 | + | } | |||
| 8401 | + | ||||
| 8402 | + | func (_c *MockOmniboxPreferencesSaver_SaveOmniboxInitialBehavior_Call) RunAndReturn(run func(ctx context.Context, behavior entity.OmniboxInitialBehavior) error) *MockOmniboxPreferencesSaver_SaveOmniboxInitialBehavior_Call { | |||
| 8403 | + | _c.Call.Return(run) | |||
| 8404 | + | return _c | |||
| 8405 | + | } | |||
| 8406 | + | ||||
| 8323 | // NewMockPermissionDialogPresenter creates a new instance of MockPermissionDialogPresenter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | 8407 | // NewMockPermissionDialogPresenter creates a new instance of MockPermissionDialogPresenter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | ||
| 8324 | // The first argument is typically a *testing.T value. | 8408 | // The first argument is typically a *testing.T value. | ||
| 8325 | func NewMockPermissionDialogPresenter(t interface { | 8409 | func NewMockPermissionDialogPresenter(t interface { | ||
A internal/application/port/omnibox_preferences.go +12 -0
| @@ -0,0 +1,12 @@ | |||
| 1 | + | package port | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // OmniboxPreferencesSaver persists omnibox preference changes. | |
| 10 | + | type OmniboxPreferencesSaver interface { | |
| 11 | + | SaveOmniboxInitialBehavior(ctx context.Context, behavior entity.OmniboxInitialBehavior) error | |
| 12 | + | } | |
| @@ -0,0 +1,12 @@ | |||||
| 1 | + | package port | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // OmniboxPreferencesSaver persists omnibox preference changes. | |||
| 10 | + | type OmniboxPreferencesSaver interface { | |||
| 11 | + | SaveOmniboxInitialBehavior(ctx context.Context, behavior entity.OmniboxInitialBehavior) error | |||
| 12 | + | } | |||
A internal/application/usecase/save_omnibox_initial_behavior.go +36 -0
| @@ -0,0 +1,36 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "strings" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | type SaveOmniboxInitialBehaviorUseCase struct { | |
| 13 | + | saver port.OmniboxPreferencesSaver | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | func NewSaveOmniboxInitialBehaviorUseCase(saver port.OmniboxPreferencesSaver) *SaveOmniboxInitialBehaviorUseCase { | |
| 17 | + | return &SaveOmniboxInitialBehaviorUseCase{saver: saver} | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func (uc *SaveOmniboxInitialBehaviorUseCase) Execute(ctx context.Context, behavior entity.OmniboxInitialBehavior) error { | |
| 21 | + | if uc == nil || uc.saver == nil { | |
| 22 | + | return fmt.Errorf("omnibox preferences saver is nil") | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | behavior = entity.OmniboxInitialBehavior(strings.TrimSpace(string(behavior))) | |
| 26 | + | switch behavior { | |
| 27 | + | case entity.OmniboxInitialBehaviorRecent, entity.OmniboxInitialBehaviorMostVisited: | |
| 28 | + | return uc.saver.SaveOmniboxInitialBehavior(ctx, behavior) | |
| 29 | + | default: | |
| 30 | + | return fmt.Errorf( | |
| 31 | + | "omnibox.initial_behavior must be one of: %s, %s", | |
| 32 | + | entity.OmniboxInitialBehaviorRecent, | |
| 33 | + | entity.OmniboxInitialBehaviorMostVisited, | |
| 34 | + | ) | |
| 35 | + | } | |
| 36 | + | } | |
| @@ -0,0 +1,36 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "strings" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | type SaveOmniboxInitialBehaviorUseCase struct { | |||
| 13 | + | saver port.OmniboxPreferencesSaver | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | func NewSaveOmniboxInitialBehaviorUseCase(saver port.OmniboxPreferencesSaver) *SaveOmniboxInitialBehaviorUseCase { | |||
| 17 | + | return &SaveOmniboxInitialBehaviorUseCase{saver: saver} | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | func (uc *SaveOmniboxInitialBehaviorUseCase) Execute(ctx context.Context, behavior entity.OmniboxInitialBehavior) error { | |||
| 21 | + | if uc == nil || uc.saver == nil { | |||
| 22 | + | return fmt.Errorf("omnibox preferences saver is nil") | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | behavior = entity.OmniboxInitialBehavior(strings.TrimSpace(string(behavior))) | |||
| 26 | + | switch behavior { | |||
| 27 | + | case entity.OmniboxInitialBehaviorRecent, entity.OmniboxInitialBehaviorMostVisited: | |||
| 28 | + | return uc.saver.SaveOmniboxInitialBehavior(ctx, behavior) | |||
| 29 | + | default: | |||
| 30 | + | return fmt.Errorf( | |||
| 31 | + | "omnibox.initial_behavior must be one of: %s, %s", | |||
| 32 | + | entity.OmniboxInitialBehaviorRecent, | |||
| 33 | + | entity.OmniboxInitialBehaviorMostVisited, | |||
| 34 | + | ) | |||
| 35 | + | } | |||
| 36 | + | } | |||
A internal/application/usecase/save_omnibox_initial_behavior_test.go +73 -0
| @@ -0,0 +1,73 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestSaveOmniboxInitialBehaviorUseCase_AllowsRecent(t *testing.T) { | |
| 12 | + | ctx := context.Background() | |
| 13 | + | saver := portmocks.NewMockOmniboxPreferencesSaver(t) | |
| 14 | + | saver.EXPECT().SaveOmniboxInitialBehavior(ctx, entity.OmniboxInitialBehaviorRecent).Return(nil).Once() | |
| 15 | + | uc := NewSaveOmniboxInitialBehaviorUseCase(saver) | |
| 16 | + | ||
| 17 | + | err := uc.Execute(ctx, entity.OmniboxInitialBehaviorRecent) | |
| 18 | + | if err != nil { | |
| 19 | + | t.Fatalf("Execute() unexpected error: %v", err) | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func TestSaveOmniboxInitialBehaviorUseCase_AllowsTrimmedMostVisited(t *testing.T) { | |
| 24 | + | ctx := context.Background() | |
| 25 | + | saver := portmocks.NewMockOmniboxPreferencesSaver(t) | |
| 26 | + | saver.EXPECT().SaveOmniboxInitialBehavior(ctx, entity.OmniboxInitialBehaviorMostVisited).Return(nil).Once() | |
| 27 | + | uc := NewSaveOmniboxInitialBehaviorUseCase(saver) | |
| 28 | + | ||
| 29 | + | err := uc.Execute(ctx, entity.OmniboxInitialBehavior(" most_visited \t")) | |
| 30 | + | if err != nil { | |
| 31 | + | t.Fatalf("Execute() unexpected error: %v", err) | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func TestSaveOmniboxInitialBehaviorUseCase_RejectsNone(t *testing.T) { | |
| 36 | + | saver := portmocks.NewMockOmniboxPreferencesSaver(t) | |
| 37 | + | uc := NewSaveOmniboxInitialBehaviorUseCase(saver) | |
| 38 | + | ||
| 39 | + | err := uc.Execute(context.Background(), entity.OmniboxInitialBehaviorNone) | |
| 40 | + | if err == nil { | |
| 41 | + | t.Fatal("Execute() error = nil, want non-nil") | |
| 42 | + | } | |
| 43 | + | if got := err.Error(); got != "omnibox.initial_behavior must be one of: recent, most_visited" && | |
| 44 | + | got != "omnibox.initial_behavior must be one of: recent, most_visited (got: none)" { | |
| 45 | + | t.Fatalf("Execute() error = %q, want validation error", got) | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func TestSaveOmniboxInitialBehaviorUseCase_RejectsEmptyString(t *testing.T) { | |
| 50 | + | saver := portmocks.NewMockOmniboxPreferencesSaver(t) | |
| 51 | + | uc := NewSaveOmniboxInitialBehaviorUseCase(saver) | |
| 52 | + | ||
| 53 | + | err := uc.Execute(context.Background(), entity.OmniboxInitialBehavior("")) | |
| 54 | + | if err == nil { | |
| 55 | + | t.Fatal("Execute() error = nil, want non-nil") | |
| 56 | + | } | |
| 57 | + | if got := err.Error(); got != "omnibox.initial_behavior must be one of: recent, most_visited" && | |
| 58 | + | got != "omnibox.initial_behavior must be one of: recent, most_visited (got: )" { | |
| 59 | + | t.Fatalf("Execute() error = %q, want validation error", got) | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func TestSaveOmniboxInitialBehaviorUseCase_NilSaverReturnsError(t *testing.T) { | |
| 64 | + | uc := NewSaveOmniboxInitialBehaviorUseCase(nil) | |
| 65 | + | ||
| 66 | + | err := uc.Execute(context.Background(), entity.OmniboxInitialBehaviorRecent) | |
| 67 | + | if err == nil { | |
| 68 | + | t.Fatal("Execute() error = nil, want non-nil") | |
| 69 | + | } | |
| 70 | + | if err.Error() != "omnibox preferences saver is nil" { | |
| 71 | + | t.Fatalf("Execute() error = %q, want nil saver error", err.Error()) | |
| 72 | + | } | |
| 73 | + | } | |
| @@ -0,0 +1,73 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |||
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestSaveOmniboxInitialBehaviorUseCase_AllowsRecent(t *testing.T) { | |||
| 12 | + | ctx := context.Background() | |||
| 13 | + | saver := portmocks.NewMockOmniboxPreferencesSaver(t) | |||
| 14 | + | saver.EXPECT().SaveOmniboxInitialBehavior(ctx, entity.OmniboxInitialBehaviorRecent).Return(nil).Once() | |||
| 15 | + | uc := NewSaveOmniboxInitialBehaviorUseCase(saver) | |||
| 16 | + | ||||
| 17 | + | err := uc.Execute(ctx, entity.OmniboxInitialBehaviorRecent) | |||
| 18 | + | if err != nil { | |||
| 19 | + | t.Fatalf("Execute() unexpected error: %v", err) | |||
| 20 | + | } | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func TestSaveOmniboxInitialBehaviorUseCase_AllowsTrimmedMostVisited(t *testing.T) { | |||
| 24 | + | ctx := context.Background() | |||
| 25 | + | saver := portmocks.NewMockOmniboxPreferencesSaver(t) | |||
| 26 | + | saver.EXPECT().SaveOmniboxInitialBehavior(ctx, entity.OmniboxInitialBehaviorMostVisited).Return(nil).Once() | |||
| 27 | + | uc := NewSaveOmniboxInitialBehaviorUseCase(saver) | |||
| 28 | + | ||||
| 29 | + | err := uc.Execute(ctx, entity.OmniboxInitialBehavior(" most_visited \t")) | |||
| 30 | + | if err != nil { | |||
| 31 | + | t.Fatalf("Execute() unexpected error: %v", err) | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func TestSaveOmniboxInitialBehaviorUseCase_RejectsNone(t *testing.T) { | |||
| 36 | + | saver := portmocks.NewMockOmniboxPreferencesSaver(t) | |||
| 37 | + | uc := NewSaveOmniboxInitialBehaviorUseCase(saver) | |||
| 38 | + | ||||
| 39 | + | err := uc.Execute(context.Background(), entity.OmniboxInitialBehaviorNone) | |||
| 40 | + | if err == nil { | |||
| 41 | + | t.Fatal("Execute() error = nil, want non-nil") | |||
| 42 | + | } | |||
| 43 | + | if got := err.Error(); got != "omnibox.initial_behavior must be one of: recent, most_visited" && | |||
| 44 | + | got != "omnibox.initial_behavior must be one of: recent, most_visited (got: none)" { | |||
| 45 | + | t.Fatalf("Execute() error = %q, want validation error", got) | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func TestSaveOmniboxInitialBehaviorUseCase_RejectsEmptyString(t *testing.T) { | |||
| 50 | + | saver := portmocks.NewMockOmniboxPreferencesSaver(t) | |||
| 51 | + | uc := NewSaveOmniboxInitialBehaviorUseCase(saver) | |||
| 52 | + | ||||
| 53 | + | err := uc.Execute(context.Background(), entity.OmniboxInitialBehavior("")) | |||
| 54 | + | if err == nil { | |||
| 55 | + | t.Fatal("Execute() error = nil, want non-nil") | |||
| 56 | + | } | |||
| 57 | + | if got := err.Error(); got != "omnibox.initial_behavior must be one of: recent, most_visited" && | |||
| 58 | + | got != "omnibox.initial_behavior must be one of: recent, most_visited (got: )" { | |||
| 59 | + | t.Fatalf("Execute() error = %q, want validation error", got) | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func TestSaveOmniboxInitialBehaviorUseCase_NilSaverReturnsError(t *testing.T) { | |||
| 64 | + | uc := NewSaveOmniboxInitialBehaviorUseCase(nil) | |||
| 65 | + | ||||
| 66 | + | err := uc.Execute(context.Background(), entity.OmniboxInitialBehaviorRecent) | |||
| 67 | + | if err == nil { | |||
| 68 | + | t.Fatal("Execute() error = nil, want non-nil") | |||
| 69 | + | } | |||
| 70 | + | if err.Error() != "omnibox preferences saver is nil" { | |||
| 71 | + | t.Fatalf("Execute() error = %q, want nil saver error", err.Error()) | |||
| 72 | + | } | |||
| 73 | + | } | |||
M internal/bootstrap/handler_deps.go +7 -5
| @@ -20,12 +20,14 @@ func BuildHandlerDeps(_ context.Context) (*port.HandlerDeps, error) { | |||
| 20 | 20 | ||
| 21 | 21 | gateway := config.NewKeybindingsGateway(mgr) | |
| 22 | 22 | saveUC := usecase.NewSaveWebUIConfigUseCase(config.NewWebUIConfigGateway(mgr)) | |
| 23 | + | omniboxSaveUC := usecase.NewSaveOmniboxInitialBehaviorUseCase(config.NewOmniboxPreferencesGateway(mgr)) | |
| 23 | 24 | ||
| 24 | 25 | return &port.HandlerDeps{ | |
| 25 | - | SaveConfig: saveUC.Execute, | |
| 26 | - | KeybindingsGetter: usecase.NewGetKeybindingsUseCase(gateway), | |
| 27 | - | KeybindingSetter: usecase.NewSetKeybindingUseCase(gateway, gateway), | |
| 28 | - | KeybindingResetter: usecase.NewResetKeybindingUseCase(gateway), | |
| 29 | - | AllKeybindingsResetter: usecase.NewResetAllKeybindingsUseCase(gateway), | |
| 26 | + | SaveConfig: saveUC.Execute, | |
| 27 | + | SaveOmniboxInitialBehavior: omniboxSaveUC.Execute, | |
| 28 | + | KeybindingsGetter: usecase.NewGetKeybindingsUseCase(gateway), | |
| 29 | + | KeybindingSetter: usecase.NewSetKeybindingUseCase(gateway, gateway), | |
| 30 | + | KeybindingResetter: usecase.NewResetKeybindingUseCase(gateway), | |
| 31 | + | AllKeybindingsResetter: usecase.NewResetAllKeybindingsUseCase(gateway), | |
| 30 | 32 | }, nil | |
| 31 | 33 | } | |
| @@ -20,12 +20,14 @@ func BuildHandlerDeps(_ context.Context) (*port.HandlerDeps, error) { | |||||
| 20 | 20 | ||||
| 21 | gateway := config.NewKeybindingsGateway(mgr) | 21 | gateway := config.NewKeybindingsGateway(mgr) | ||
| 22 | saveUC := usecase.NewSaveWebUIConfigUseCase(config.NewWebUIConfigGateway(mgr)) | 22 | saveUC := usecase.NewSaveWebUIConfigUseCase(config.NewWebUIConfigGateway(mgr)) | ||
| 23 | + | omniboxSaveUC := usecase.NewSaveOmniboxInitialBehaviorUseCase(config.NewOmniboxPreferencesGateway(mgr)) | |||
| 23 | 24 | ||||
| 24 | return &port.HandlerDeps{ | 25 | return &port.HandlerDeps{ | ||
| 25 | - | SaveConfig: saveUC.Execute, | 26 | + | SaveConfig: saveUC.Execute, |
| 26 | - | KeybindingsGetter: usecase.NewGetKeybindingsUseCase(gateway), | 27 | + | SaveOmniboxInitialBehavior: omniboxSaveUC.Execute, |
| 27 | - | KeybindingSetter: usecase.NewSetKeybindingUseCase(gateway, gateway), | 28 | + | KeybindingsGetter: usecase.NewGetKeybindingsUseCase(gateway), |
| 28 | - | KeybindingResetter: usecase.NewResetKeybindingUseCase(gateway), | 29 | + | KeybindingSetter: usecase.NewSetKeybindingUseCase(gateway, gateway), |
| 29 | - | AllKeybindingsResetter: usecase.NewResetAllKeybindingsUseCase(gateway), | 30 | + | KeybindingResetter: usecase.NewResetKeybindingUseCase(gateway), |
| 31 | + | AllKeybindingsResetter: usecase.NewResetAllKeybindingsUseCase(gateway), | |||
| 30 | }, nil | 32 | }, nil | ||
| 31 | } | 33 | } | ||
M internal/domain/entity/config_types.go +9 -0
| @@ -157,6 +157,15 @@ const ( | |||
| 157 | 157 | PopupBehaviorWindowed PopupBehavior = "windowed" | |
| 158 | 158 | ) | |
| 159 | 159 | ||
| 160 | + | // OmniboxInitialBehavior controls what the omnibox shows for empty input. | |
| 161 | + | type OmniboxInitialBehavior string | |
| 162 | + | ||
| 163 | + | const ( | |
| 164 | + | OmniboxInitialBehaviorRecent OmniboxInitialBehavior = "recent" | |
| 165 | + | OmniboxInitialBehaviorMostVisited OmniboxInitialBehavior = "most_visited" | |
| 166 | + | OmniboxInitialBehaviorNone OmniboxInitialBehavior = "none" | |
| 167 | + | ) | |
| 168 | + | ||
| 160 | 169 | // PopupBehaviorConfig controls how popup windows are handled. | |
| 161 | 170 | type PopupBehaviorConfig struct { | |
| 162 | 171 | Behavior PopupBehavior `mapstructure:"behavior" yaml:"behavior" toml:"behavior" json:"behavior"` | |
| @@ -157,6 +157,15 @@ const ( | |||||
| 157 | PopupBehaviorWindowed PopupBehavior = "windowed" | 157 | PopupBehaviorWindowed PopupBehavior = "windowed" | ||
| 158 | ) | 158 | ) | ||
| 159 | 159 | ||||
| 160 | + | // OmniboxInitialBehavior controls what the omnibox shows for empty input. | |||
| 161 | + | type OmniboxInitialBehavior string | |||
| 162 | + | ||||
| 163 | + | const ( | |||
| 164 | + | OmniboxInitialBehaviorRecent OmniboxInitialBehavior = "recent" | |||
| 165 | + | OmniboxInitialBehaviorMostVisited OmniboxInitialBehavior = "most_visited" | |||
| 166 | + | OmniboxInitialBehaviorNone OmniboxInitialBehavior = "none" | |||
| 167 | + | ) | |||
| 168 | + | ||||
| 160 | // PopupBehaviorConfig controls how popup windows are handled. | 169 | // PopupBehaviorConfig controls how popup windows are handled. | ||
| 161 | type PopupBehaviorConfig struct { | 170 | type PopupBehaviorConfig struct { | ||
| 162 | Behavior PopupBehavior `mapstructure:"behavior" yaml:"behavior" toml:"behavior" json:"behavior"` | 171 | Behavior PopupBehavior `mapstructure:"behavior" yaml:"behavior" toml:"behavior" json:"behavior"` | ||
M internal/infrastructure/config/defaults.go +1 -1
| @@ -19,7 +19,7 @@ const ( | |||
| 19 | 19 | defaultNewPaneURL = "about:blank" | |
| 20 | 20 | ||
| 21 | 21 | // Omnibox defaults | |
| 22 | - | defaultOmniboxInitialBehavior = "recent" | |
| 22 | + | defaultOmniboxInitialBehavior = OmniboxInitialBehaviorRecent | |
| 23 | 23 | defaultOmniboxAutoOpenOnNewPane = false | |
| 24 | 24 | ||
| 25 | 25 | // Workspace defaults | |
| @@ -19,7 +19,7 @@ const ( | |||||
| 19 | defaultNewPaneURL = "about:blank" | 19 | defaultNewPaneURL = "about:blank" | ||
| 20 | 20 | ||||
| 21 | // Omnibox defaults | 21 | // Omnibox defaults | ||
| 22 | - | defaultOmniboxInitialBehavior = "recent" | 22 | + | defaultOmniboxInitialBehavior = OmniboxInitialBehaviorRecent |
| 23 | defaultOmniboxAutoOpenOnNewPane = false | 23 | defaultOmniboxAutoOpenOnNewPane = false | ||
| 24 | 24 | ||||
| 25 | // Workspace defaults | 25 | // Workspace defaults | ||
A internal/infrastructure/config/omnibox_preferences_gateway.go +27 -0
| @@ -0,0 +1,27 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | type OmniboxPreferencesGateway struct { | |
| 11 | + | mgr *Manager | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | func NewOmniboxPreferencesGateway(mgr *Manager) *OmniboxPreferencesGateway { | |
| 15 | + | return &OmniboxPreferencesGateway{mgr: mgr} | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func (g *OmniboxPreferencesGateway) SaveOmniboxInitialBehavior(_ context.Context, behavior entity.OmniboxInitialBehavior) error { | |
| 19 | + | if g == nil || g.mgr == nil { | |
| 20 | + | return fmt.Errorf("config manager not initialized") | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | current := g.mgr.Get() | |
| 24 | + | current.Omnibox.InitialBehavior = behavior | |
| 25 | + | ||
| 26 | + | return g.mgr.Save(current) | |
| 27 | + | } | |
| @@ -0,0 +1,27 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | type OmniboxPreferencesGateway struct { | |||
| 11 | + | mgr *Manager | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | func NewOmniboxPreferencesGateway(mgr *Manager) *OmniboxPreferencesGateway { | |||
| 15 | + | return &OmniboxPreferencesGateway{mgr: mgr} | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func (g *OmniboxPreferencesGateway) SaveOmniboxInitialBehavior(_ context.Context, behavior entity.OmniboxInitialBehavior) error { | |||
| 19 | + | if g == nil || g.mgr == nil { | |||
| 20 | + | return fmt.Errorf("config manager not initialized") | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | current := g.mgr.Get() | |||
| 24 | + | current.Omnibox.InitialBehavior = behavior | |||
| 25 | + | ||||
| 26 | + | return g.mgr.Save(current) | |||
| 27 | + | } | |||
A internal/infrastructure/config/omnibox_preferences_gateway_test.go +50 -0
| @@ -0,0 +1,50 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "path/filepath" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestOmniboxPreferencesGateway_SaveOmniboxInitialBehavior_UpdatesBehavior(t *testing.T) { | |
| 13 | + | configHome := t.TempDir() | |
| 14 | + | mgr := newTestOmniboxPreferencesGatewayManager(t, configHome) | |
| 15 | + | gateway := NewOmniboxPreferencesGateway(mgr) | |
| 16 | + | ||
| 17 | + | require.NoError(t, gateway.SaveOmniboxInitialBehavior(context.Background(), entity.OmniboxInitialBehaviorMostVisited)) | |
| 18 | + | ||
| 19 | + | reloaded := newTestOmniboxPreferencesGatewayManager(t, configHome) | |
| 20 | + | got := reloaded.Get() | |
| 21 | + | require.Equal(t, entity.OmniboxInitialBehaviorMostVisited, got.Omnibox.InitialBehavior) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func TestOmniboxPreferencesGateway_SaveOmniboxInitialBehavior_PreservesDefaultSearchEngine(t *testing.T) { | |
| 25 | + | configHome := t.TempDir() | |
| 26 | + | mgr := newTestOmniboxPreferencesGatewayManager(t, configHome) | |
| 27 | + | mgr.config.DefaultSearchEngine = "https://example.com/search?q=%s" | |
| 28 | + | gateway := NewOmniboxPreferencesGateway(mgr) | |
| 29 | + | ||
| 30 | + | require.NoError(t, gateway.SaveOmniboxInitialBehavior(context.Background(), entity.OmniboxInitialBehaviorMostVisited)) | |
| 31 | + | ||
| 32 | + | reloaded := newTestOmniboxPreferencesGatewayManager(t, configHome) | |
| 33 | + | got := reloaded.Get() | |
| 34 | + | require.Equal(t, "https://example.com/search?q=%s", got.DefaultSearchEngine) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func newTestOmniboxPreferencesGatewayManager(t *testing.T, configHome string) *Manager { | |
| 38 | + | t.Helper() | |
| 39 | + | ||
| 40 | + | t.Setenv("XDG_CONFIG_HOME", configHome) | |
| 41 | + | t.Setenv("XDG_DATA_HOME", filepath.Join(configHome, "data")) | |
| 42 | + | t.Setenv("XDG_STATE_HOME", filepath.Join(configHome, "state")) | |
| 43 | + | t.Setenv("XDG_CACHE_HOME", filepath.Join(configHome, "cache")) | |
| 44 | + | ||
| 45 | + | mgr, err := NewManager() | |
| 46 | + | require.NoError(t, err) | |
| 47 | + | require.NoError(t, mgr.Load()) | |
| 48 | + | ||
| 49 | + | return mgr | |
| 50 | + | } | |
| @@ -0,0 +1,50 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestOmniboxPreferencesGateway_SaveOmniboxInitialBehavior_UpdatesBehavior(t *testing.T) { | |||
| 13 | + | configHome := t.TempDir() | |||
| 14 | + | mgr := newTestOmniboxPreferencesGatewayManager(t, configHome) | |||
| 15 | + | gateway := NewOmniboxPreferencesGateway(mgr) | |||
| 16 | + | ||||
| 17 | + | require.NoError(t, gateway.SaveOmniboxInitialBehavior(context.Background(), entity.OmniboxInitialBehaviorMostVisited)) | |||
| 18 | + | ||||
| 19 | + | reloaded := newTestOmniboxPreferencesGatewayManager(t, configHome) | |||
| 20 | + | got := reloaded.Get() | |||
| 21 | + | require.Equal(t, entity.OmniboxInitialBehaviorMostVisited, got.Omnibox.InitialBehavior) | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func TestOmniboxPreferencesGateway_SaveOmniboxInitialBehavior_PreservesDefaultSearchEngine(t *testing.T) { | |||
| 25 | + | configHome := t.TempDir() | |||
| 26 | + | mgr := newTestOmniboxPreferencesGatewayManager(t, configHome) | |||
| 27 | + | mgr.config.DefaultSearchEngine = "https://example.com/search?q=%s" | |||
| 28 | + | gateway := NewOmniboxPreferencesGateway(mgr) | |||
| 29 | + | ||||
| 30 | + | require.NoError(t, gateway.SaveOmniboxInitialBehavior(context.Background(), entity.OmniboxInitialBehaviorMostVisited)) | |||
| 31 | + | ||||
| 32 | + | reloaded := newTestOmniboxPreferencesGatewayManager(t, configHome) | |||
| 33 | + | got := reloaded.Get() | |||
| 34 | + | require.Equal(t, "https://example.com/search?q=%s", got.DefaultSearchEngine) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func newTestOmniboxPreferencesGatewayManager(t *testing.T, configHome string) *Manager { | |||
| 38 | + | t.Helper() | |||
| 39 | + | ||||
| 40 | + | t.Setenv("XDG_CONFIG_HOME", configHome) | |||
| 41 | + | t.Setenv("XDG_DATA_HOME", filepath.Join(configHome, "data")) | |||
| 42 | + | t.Setenv("XDG_STATE_HOME", filepath.Join(configHome, "state")) | |||
| 43 | + | t.Setenv("XDG_CACHE_HOME", filepath.Join(configHome, "cache")) | |||
| 44 | + | ||||
| 45 | + | mgr, err := NewManager() | |||
| 46 | + | require.NoError(t, err) | |||
| 47 | + | require.NoError(t, mgr.Load()) | |||
| 48 | + | ||||
| 49 | + | return mgr | |||
| 50 | + | } | |||
M internal/infrastructure/config/schema.go +13 -1
| @@ -56,6 +56,18 @@ const ( | |||
| 56 | 56 | PopupBehaviorWindowed = entity.PopupBehaviorWindowed | |
| 57 | 57 | ) | |
| 58 | 58 | ||
| 59 | + | // OmniboxInitialBehavior defines how omnibox history is ordered on open. | |
| 60 | + | type OmniboxInitialBehavior = entity.OmniboxInitialBehavior | |
| 61 | + | ||
| 62 | + | const ( | |
| 63 | + | // OmniboxInitialBehaviorRecent shows recent visits first. | |
| 64 | + | OmniboxInitialBehaviorRecent = entity.OmniboxInitialBehaviorRecent | |
| 65 | + | // OmniboxInitialBehaviorMostVisited shows most visited sites first. | |
| 66 | + | OmniboxInitialBehaviorMostVisited = entity.OmniboxInitialBehaviorMostVisited | |
| 67 | + | // OmniboxInitialBehaviorNone shows no initial history. | |
| 68 | + | OmniboxInitialBehaviorNone = entity.OmniboxInitialBehaviorNone | |
| 69 | + | ) | |
| 70 | + | ||
| 59 | 71 | // PopupBehaviorConfig defines handling for popup windows. | |
| 60 | 72 | type PopupBehaviorConfig = entity.PopupBehaviorConfig | |
| 61 | 73 | ||
| @@ -346,7 +358,7 @@ type ClipboardConfig struct { | |||
| 346 | 358 | type OmniboxConfig struct { | |
| 347 | 359 | // InitialBehavior controls what to show when omnibox opens with empty input | |
| 348 | 360 | // Values: "recent" (recent visits), "most_visited" (most visited sites), "none" (nothing) | |
| 349 | - | InitialBehavior string `mapstructure:"initial_behavior" yaml:"initial_behavior" toml:"initial_behavior"` | |
| 361 | + | InitialBehavior OmniboxInitialBehavior `mapstructure:"initial_behavior" yaml:"initial_behavior" toml:"initial_behavior"` | |
| 350 | 362 | // AutoOpenOnNewPane opens the omnibox automatically when a new pane is created. | |
| 351 | 363 | // Default: false | |
| 352 | 364 | AutoOpenOnNewPane bool `mapstructure:"auto_open_on_new_pane" yaml:"auto_open_on_new_pane" toml:"auto_open_on_new_pane"` | |
| @@ -56,6 +56,18 @@ const ( | |||||
| 56 | PopupBehaviorWindowed = entity.PopupBehaviorWindowed | 56 | PopupBehaviorWindowed = entity.PopupBehaviorWindowed | ||
| 57 | ) | 57 | ) | ||
| 58 | 58 | ||||
| 59 | + | // OmniboxInitialBehavior defines how omnibox history is ordered on open. | |||
| 60 | + | type OmniboxInitialBehavior = entity.OmniboxInitialBehavior | |||
| 61 | + | ||||
| 62 | + | const ( | |||
| 63 | + | // OmniboxInitialBehaviorRecent shows recent visits first. | |||
| 64 | + | OmniboxInitialBehaviorRecent = entity.OmniboxInitialBehaviorRecent | |||
| 65 | + | // OmniboxInitialBehaviorMostVisited shows most visited sites first. | |||
| 66 | + | OmniboxInitialBehaviorMostVisited = entity.OmniboxInitialBehaviorMostVisited | |||
| 67 | + | // OmniboxInitialBehaviorNone shows no initial history. | |||
| 68 | + | OmniboxInitialBehaviorNone = entity.OmniboxInitialBehaviorNone | |||
| 69 | + | ) | |||
| 70 | + | ||||
| 59 | // PopupBehaviorConfig defines handling for popup windows. | 71 | // PopupBehaviorConfig defines handling for popup windows. | ||
| 60 | type PopupBehaviorConfig = entity.PopupBehaviorConfig | 72 | type PopupBehaviorConfig = entity.PopupBehaviorConfig | ||
| 61 | 73 | ||||
| @@ -346,7 +358,7 @@ type ClipboardConfig struct { | |||||
| 346 | type OmniboxConfig struct { | 358 | type OmniboxConfig struct { | ||
| 347 | // InitialBehavior controls what to show when omnibox opens with empty input | 359 | // InitialBehavior controls what to show when omnibox opens with empty input | ||
| 348 | // Values: "recent" (recent visits), "most_visited" (most visited sites), "none" (nothing) | 360 | // Values: "recent" (recent visits), "most_visited" (most visited sites), "none" (nothing) | ||
| 349 | - | InitialBehavior string `mapstructure:"initial_behavior" yaml:"initial_behavior" toml:"initial_behavior"` | 361 | + | InitialBehavior OmniboxInitialBehavior `mapstructure:"initial_behavior" yaml:"initial_behavior" toml:"initial_behavior"` |
| 350 | // AutoOpenOnNewPane opens the omnibox automatically when a new pane is created. | 362 | // AutoOpenOnNewPane opens the omnibox automatically when a new pane is created. | ||
| 351 | // Default: false | 363 | // Default: false | ||
| 352 | AutoOpenOnNewPane bool `mapstructure:"auto_open_on_new_pane" yaml:"auto_open_on_new_pane" toml:"auto_open_on_new_pane"` | 364 | AutoOpenOnNewPane bool `mapstructure:"auto_open_on_new_pane" yaml:"auto_open_on_new_pane" toml:"auto_open_on_new_pane"` | ||
M internal/infrastructure/config/schema_provider.go +7 -3
| @@ -663,10 +663,14 @@ func (*SchemaProvider) getOmniboxKeys(defaults *Config) []entity.ConfigKeyInfo { | |||
| 663 | 663 | { | |
| 664 | 664 | Key: "omnibox.initial_behavior", | |
| 665 | 665 | Type: "string", | |
| 666 | - | Default: defaults.Omnibox.InitialBehavior, | |
| 666 | + | Default: string(defaults.Omnibox.InitialBehavior), | |
| 667 | 667 | Description: "What to show when omnibox opens with empty input", | |
| 668 | - | Values: []string{"recent", "most_visited", "none"}, | |
| 669 | - | Section: SectionOmnibox, | |
| 668 | + | Values: []string{ | |
| 669 | + | string(OmniboxInitialBehaviorRecent), | |
| 670 | + | string(OmniboxInitialBehaviorMostVisited), | |
| 671 | + | string(OmniboxInitialBehaviorNone), | |
| 672 | + | }, | |
| 673 | + | Section: SectionOmnibox, | |
| 670 | 674 | }, | |
| 671 | 675 | { | |
| 672 | 676 | Key: "omnibox.auto_open_on_new_pane", | |
| @@ -663,10 +663,14 @@ func (*SchemaProvider) getOmniboxKeys(defaults *Config) []entity.ConfigKeyInfo { | |||||
| 663 | { | 663 | { | ||
| 664 | Key: "omnibox.initial_behavior", | 664 | Key: "omnibox.initial_behavior", | ||
| 665 | Type: "string", | 665 | Type: "string", | ||
| 666 | - | Default: defaults.Omnibox.InitialBehavior, | 666 | + | Default: string(defaults.Omnibox.InitialBehavior), |
| 667 | Description: "What to show when omnibox opens with empty input", | 667 | Description: "What to show when omnibox opens with empty input", | ||
| 668 | - | Values: []string{"recent", "most_visited", "none"}, | 668 | + | Values: []string{ |
| 669 | - | Section: SectionOmnibox, | 669 | + | string(OmniboxInitialBehaviorRecent), |
| 670 | + | string(OmniboxInitialBehaviorMostVisited), | |||
| 671 | + | string(OmniboxInitialBehaviorNone), | |||
| 672 | + | }, | |||
| 673 | + | Section: SectionOmnibox, | |||
| 670 | }, | 674 | }, | ||
| 671 | { | 675 | { | ||
| 672 | Key: "omnibox.auto_open_on_new_pane", | 676 | Key: "omnibox.auto_open_on_new_pane", | ||
M internal/infrastructure/config/validation.go +5 -2
| @@ -389,11 +389,14 @@ func validateWorkspaceURLValue(fieldPath, value string) []string { | |||
| 389 | 389 | ||
| 390 | 390 | func validateOmnibox(config *Config) []string { | |
| 391 | 391 | switch config.Omnibox.InitialBehavior { | |
| 392 | - | case "recent", "most_visited", "none": | |
| 392 | + | case OmniboxInitialBehaviorRecent, OmniboxInitialBehaviorMostVisited, OmniboxInitialBehaviorNone: | |
| 393 | 393 | return nil | |
| 394 | 394 | default: | |
| 395 | 395 | return []string{fmt.Sprintf( | |
| 396 | - | "omnibox.initial_behavior must be one of: recent, most_visited, none (got: %s)", | |
| 396 | + | "omnibox.initial_behavior must be one of: %s, %s, %s (got: %s)", | |
| 397 | + | OmniboxInitialBehaviorRecent, | |
| 398 | + | OmniboxInitialBehaviorMostVisited, | |
| 399 | + | OmniboxInitialBehaviorNone, | |
| 397 | 400 | config.Omnibox.InitialBehavior, | |
| 398 | 401 | )} | |
| 399 | 402 | } | |
| @@ -389,11 +389,14 @@ func validateWorkspaceURLValue(fieldPath, value string) []string { | |||||
| 389 | 389 | ||||
| 390 | func validateOmnibox(config *Config) []string { | 390 | func validateOmnibox(config *Config) []string { | ||
| 391 | switch config.Omnibox.InitialBehavior { | 391 | switch config.Omnibox.InitialBehavior { | ||
| 392 | - | case "recent", "most_visited", "none": | 392 | + | case OmniboxInitialBehaviorRecent, OmniboxInitialBehaviorMostVisited, OmniboxInitialBehaviorNone: |
| 393 | return nil | 393 | return nil | ||
| 394 | default: | 394 | default: | ||
| 395 | return []string{fmt.Sprintf( | 395 | return []string{fmt.Sprintf( | ||
| 396 | - | "omnibox.initial_behavior must be one of: recent, most_visited, none (got: %s)", | 396 | + | "omnibox.initial_behavior must be one of: %s, %s, %s (got: %s)", |
| 397 | + | OmniboxInitialBehaviorRecent, | |||
| 398 | + | OmniboxInitialBehaviorMostVisited, | |||
| 399 | + | OmniboxInitialBehaviorNone, | |||
| 397 | config.Omnibox.InitialBehavior, | 400 | config.Omnibox.InitialBehavior, | ||
| 398 | )} | 401 | )} | ||
| 399 | } | 402 | } | ||
M internal/ui/app.go +15 -14
| @@ -795,20 +795,21 @@ func buildOmniboxConfig( | |||
| 795 | 795 | } | |
| 796 | 796 | ||
| 797 | 797 | return component.OmniboxConfig{ | |
| 798 | - | HistoryUC: deps.HistoryUC, | |
| 799 | - | FavoritesUC: deps.FavoritesUC, | |
| 800 | - | FaviconAdapter: faviconAdapter, | |
| 801 | - | CopyURLUC: deps.CopyURLUC, | |
| 802 | - | ShortcutsUC: usecase.NewSearchShortcutsUseCase(shortcuts), | |
| 803 | - | DefaultSearch: deps.Config.DefaultSearchEngine, | |
| 804 | - | InitialBehavior: deps.Config.Omnibox.InitialBehavior, | |
| 805 | - | UIScale: deps.Config.DefaultUIScale, | |
| 806 | - | OnNavigate: callbacks.OnNavigate, | |
| 807 | - | OnToast: callbacks.OnToast, | |
| 808 | - | OnFocusIn: callbacks.OnFocusIn, | |
| 809 | - | OnFocusOut: callbacks.OnFocusOut, | |
| 810 | - | OnAccentKeyPress: callbacks.OnAccentKeyPress, | |
| 811 | - | OnAccentKeyRelease: callbacks.OnAccentKeyRelease, | |
| 798 | + | HistoryUC: deps.HistoryUC, | |
| 799 | + | FavoritesUC: deps.FavoritesUC, | |
| 800 | + | FaviconAdapter: faviconAdapter, | |
| 801 | + | CopyURLUC: deps.CopyURLUC, | |
| 802 | + | ShortcutsUC: usecase.NewSearchShortcutsUseCase(shortcuts), | |
| 803 | + | DefaultSearch: deps.Config.DefaultSearchEngine, | |
| 804 | + | InitialBehavior: deps.Config.Omnibox.InitialBehavior, | |
| 805 | + | SaveInitialBehavior: deps.HandlerDeps.SaveOmniboxInitialBehavior, | |
| 806 | + | UIScale: deps.Config.DefaultUIScale, | |
| 807 | + | OnNavigate: callbacks.OnNavigate, | |
| 808 | + | OnToast: callbacks.OnToast, | |
| 809 | + | OnFocusIn: callbacks.OnFocusIn, | |
| 810 | + | OnFocusOut: callbacks.OnFocusOut, | |
| 811 | + | OnAccentKeyPress: callbacks.OnAccentKeyPress, | |
| 812 | + | OnAccentKeyRelease: callbacks.OnAccentKeyRelease, | |
| 812 | 813 | } | |
| 813 | 814 | } | |
| 814 | 815 | ||
| @@ -795,20 +795,21 @@ func buildOmniboxConfig( | |||||
| 795 | } | 795 | } | ||
| 796 | 796 | ||||
| 797 | return component.OmniboxConfig{ | 797 | return component.OmniboxConfig{ | ||
| 798 | - | HistoryUC: deps.HistoryUC, | 798 | + | HistoryUC: deps.HistoryUC, |
| 799 | - | FavoritesUC: deps.FavoritesUC, | 799 | + | FavoritesUC: deps.FavoritesUC, |
| 800 | - | FaviconAdapter: faviconAdapter, | 800 | + | FaviconAdapter: faviconAdapter, |
| 801 | - | CopyURLUC: deps.CopyURLUC, | 801 | + | CopyURLUC: deps.CopyURLUC, |
| 802 | - | ShortcutsUC: usecase.NewSearchShortcutsUseCase(shortcuts), | 802 | + | ShortcutsUC: usecase.NewSearchShortcutsUseCase(shortcuts), |
| 803 | - | DefaultSearch: deps.Config.DefaultSearchEngine, | 803 | + | DefaultSearch: deps.Config.DefaultSearchEngine, |
| 804 | - | InitialBehavior: deps.Config.Omnibox.InitialBehavior, | 804 | + | InitialBehavior: deps.Config.Omnibox.InitialBehavior, |
| 805 | - | UIScale: deps.Config.DefaultUIScale, | 805 | + | SaveInitialBehavior: deps.HandlerDeps.SaveOmniboxInitialBehavior, |
| 806 | - | OnNavigate: callbacks.OnNavigate, | 806 | + | UIScale: deps.Config.DefaultUIScale, |
| 807 | - | OnToast: callbacks.OnToast, | 807 | + | OnNavigate: callbacks.OnNavigate, |
| 808 | - | OnFocusIn: callbacks.OnFocusIn, | 808 | + | OnToast: callbacks.OnToast, |
| 809 | - | OnFocusOut: callbacks.OnFocusOut, | 809 | + | OnFocusIn: callbacks.OnFocusIn, |
| 810 | - | OnAccentKeyPress: callbacks.OnAccentKeyPress, | 810 | + | OnFocusOut: callbacks.OnFocusOut, |
| 811 | - | OnAccentKeyRelease: callbacks.OnAccentKeyRelease, | 811 | + | OnAccentKeyPress: callbacks.OnAccentKeyPress, |
| 812 | + | OnAccentKeyRelease: callbacks.OnAccentKeyRelease, | |||
| 812 | } | 813 | } | ||
| 813 | } | 814 | } | ||
| 814 | 815 | ||||
M internal/ui/component/omnibox.go +189 -63
| @@ -27,10 +27,11 @@ import ( | |||
| 27 | 27 | ) | |
| 28 | 28 | ||
| 29 | 29 | const ( | |
| 30 | - | debounceDelayMs = 50 | |
| 31 | - | endBoxSpacing = 6 | |
| 32 | - | defaultOmniboxPlaceholder = "Search history or enter URL… (! lists bangs)" | |
| 33 | - | minGhostInputLength = 1 | |
| 30 | + | debounceDelayMs = 50 | |
| 31 | + | endBoxSpacing = 6 | |
| 32 | + | defaultOmniboxPlaceholder = "Search history or enter URL… (! lists bangs)" | |
| 33 | + | minGhostInputLength = 1 | |
| 34 | + | initialBehaviorBadgeTooltip = "Toggle default history order (Ctrl+R)" | |
| 34 | 35 | ) | |
| 35 | 36 | ||
| 36 | 37 | type favoriteRowIndicatorUpdate struct { | |
| @@ -73,16 +74,17 @@ type Favorite struct { | |||
| 73 | 74 | // Omnibox is the native GTK4 address bar / command palette. | |
| 74 | 75 | type Omnibox struct { | |
| 75 | 76 | // GTK widgets | |
| 76 | - | outerBox *gtk.Box // Outer container for positioning | |
| 77 | - | mainBox *gtk.Box // Main content box | |
| 78 | - | headerBox *gtk.Box | |
| 79 | - | historyBtn *gtk.Button | |
| 80 | - | favoritesBtn *gtk.Button | |
| 81 | - | zoomLabel *gtk.Label | |
| 82 | - | bangBadge *gtk.Label | |
| 83 | - | entry *gtk.SearchEntry | |
| 84 | - | scrolledWin *gtk.ScrolledWindow | |
| 85 | - | listBox *gtk.ListBox | |
| 77 | + | outerBox *gtk.Box // Outer container for positioning | |
| 78 | + | mainBox *gtk.Box // Main content box | |
| 79 | + | headerBox *gtk.Box | |
| 80 | + | initialBehaviorBadge *gtk.Button | |
| 81 | + | historyBtn *gtk.Button | |
| 82 | + | favoritesBtn *gtk.Button | |
| 83 | + | zoomLabel *gtk.Label | |
| 84 | + | bangBadge *gtk.Label | |
| 85 | + | entry *gtk.SearchEntry | |
| 86 | + | scrolledWin *gtk.ScrolledWindow | |
| 87 | + | listBox *gtk.ListBox | |
| 86 | 88 | ||
| 87 | 89 | // Parent overlay reference for sizing (set via SetParentOverlay) | |
| 88 | 90 | parentOverlay layout.OverlayWidget | |
| @@ -106,14 +108,15 @@ type Omnibox struct { | |||
| 106 | 108 | isSettingGhost bool // Guard flag: skip onEntryChanged during programmatic SetText | |
| 107 | 109 | ||
| 108 | 110 | // Dependencies | |
| 109 | - | historyUC *usecase.SearchHistoryUseCase | |
| 110 | - | favoritesUC *usecase.ManageFavoritesUseCase | |
| 111 | - | faviconAdapter *adapter.FaviconAdapter | |
| 112 | - | copyURLUC *usecase.CopyURLUseCase | |
| 113 | - | shortcutsUC *usecase.SearchShortcutsUseCase | |
| 114 | - | defaultSearch string | |
| 115 | - | initialBehavior string | |
| 116 | - | ctx context.Context | |
| 111 | + | historyUC *usecase.SearchHistoryUseCase | |
| 112 | + | favoritesUC *usecase.ManageFavoritesUseCase | |
| 113 | + | faviconAdapter *adapter.FaviconAdapter | |
| 114 | + | copyURLUC *usecase.CopyURLUseCase | |
| 115 | + | shortcutsUC *usecase.SearchShortcutsUseCase | |
| 116 | + | defaultSearch string | |
| 117 | + | initialBehavior entity.OmniboxInitialBehavior | |
| 118 | + | saveInitialBehaviorFn func(context.Context, entity.OmniboxInitialBehavior) error | |
| 119 | + | ctx context.Context | |
| 117 | 120 | ||
| 118 | 121 | // Callbacks | |
| 119 | 122 | onNavigate func(url string) | |
| @@ -151,21 +154,22 @@ type Omnibox struct { | |||
| 151 | 154 | ||
| 152 | 155 | // OmniboxConfig holds configuration for creating an Omnibox. | |
| 153 | 156 | type OmniboxConfig struct { | |
| 154 | - | HistoryUC *usecase.SearchHistoryUseCase | |
| 155 | - | FavoritesUC *usecase.ManageFavoritesUseCase | |
| 156 | - | FaviconAdapter *adapter.FaviconAdapter | |
| 157 | - | CopyURLUC *usecase.CopyURLUseCase | |
| 158 | - | ShortcutsUC *usecase.SearchShortcutsUseCase | |
| 159 | - | DefaultSearch string | |
| 160 | - | InitialBehavior string | |
| 161 | - | UIScale float64 // UI scale for favicon sizing | |
| 162 | - | OnNavigate func(url string) // Callback when user navigates via omnibox | |
| 163 | - | OnToast func(ctx context.Context, message string, level ToastLevel) // Callback to show toast notification | |
| 164 | - | OnFocusIn func(entry *gtk.SearchEntry) // Callback when entry gains focus (for accent picker) | |
| 165 | - | OnFocusOut func() // Callback when entry loses focus | |
| 166 | - | OnAccentKeyPress func(keyval uint, state gdk.ModifierType) bool // Long-press accent detection | |
| 167 | - | OnAccentKeyRelease func(keyval uint) // Key release for accent cancel | |
| 168 | - | SizeConfig ModalSizeConfig // Optional geometry override for omnibox sizing | |
| 157 | + | HistoryUC *usecase.SearchHistoryUseCase | |
| 158 | + | FavoritesUC *usecase.ManageFavoritesUseCase | |
| 159 | + | FaviconAdapter *adapter.FaviconAdapter | |
| 160 | + | CopyURLUC *usecase.CopyURLUseCase | |
| 161 | + | ShortcutsUC *usecase.SearchShortcutsUseCase | |
| 162 | + | DefaultSearch string | |
| 163 | + | InitialBehavior entity.OmniboxInitialBehavior | |
| 164 | + | SaveInitialBehavior func(ctx context.Context, behavior entity.OmniboxInitialBehavior) error | |
| 165 | + | UIScale float64 // UI scale for favicon sizing | |
| 166 | + | OnNavigate func(url string) // Callback when user navigates via omnibox | |
| 167 | + | OnToast func(ctx context.Context, message string, level ToastLevel) // Callback to show toast notification | |
| 168 | + | OnFocusIn func(entry *gtk.SearchEntry) // Callback when entry gains focus (for accent picker) | |
| 169 | + | OnFocusOut func() // Callback when entry loses focus | |
| 170 | + | OnAccentKeyPress func(keyval uint, state gdk.ModifierType) bool // Long-press accent detection | |
| 171 | + | OnAccentKeyRelease func(keyval uint) // Key release for accent cancel | |
| 172 | + | SizeConfig ModalSizeConfig // Optional geometry override for omnibox sizing | |
| 169 | 173 | } | |
| 170 | 174 | ||
| 171 | 175 | // NewOmnibox creates a new native GTK4 omnibox widget. | |
| @@ -181,21 +185,22 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox { | |||
| 181 | 185 | sizeCfg := ResolveModalSizeConfig(cfg.SizeConfig, OmniboxSizeDefaults) | |
| 182 | 186 | ||
| 183 | 187 | o := &Omnibox{ | |
| 184 | - | viewMode: ViewModeHistory, | |
| 185 | - | selectedIndex: -1, | |
| 186 | - | historyUC: cfg.HistoryUC, | |
| 187 | - | favoritesUC: cfg.FavoritesUC, | |
| 188 | - | faviconAdapter: cfg.FaviconAdapter, | |
| 189 | - | copyURLUC: cfg.CopyURLUC, | |
| 190 | - | shortcutsUC: cfg.ShortcutsUC, | |
| 191 | - | defaultSearch: cfg.DefaultSearch, | |
| 192 | - | initialBehavior: cfg.InitialBehavior, | |
| 193 | - | onToast: cfg.OnToast, | |
| 194 | - | onAccentKeyPress: cfg.OnAccentKeyPress, | |
| 195 | - | onAccentKeyRelease: cfg.OnAccentKeyRelease, | |
| 196 | - | ctx: ctx, | |
| 197 | - | uiScale: uiScale, | |
| 198 | - | sizeCfg: sizeCfg, | |
| 188 | + | viewMode: ViewModeHistory, | |
| 189 | + | selectedIndex: -1, | |
| 190 | + | historyUC: cfg.HistoryUC, | |
| 191 | + | favoritesUC: cfg.FavoritesUC, | |
| 192 | + | faviconAdapter: cfg.FaviconAdapter, | |
| 193 | + | copyURLUC: cfg.CopyURLUC, | |
| 194 | + | shortcutsUC: cfg.ShortcutsUC, | |
| 195 | + | defaultSearch: cfg.DefaultSearch, | |
| 196 | + | initialBehavior: cfg.InitialBehavior, | |
| 197 | + | saveInitialBehaviorFn: cfg.SaveInitialBehavior, | |
| 198 | + | onToast: cfg.OnToast, | |
| 199 | + | onAccentKeyPress: cfg.OnAccentKeyPress, | |
| 200 | + | onAccentKeyRelease: cfg.OnAccentKeyRelease, | |
| 201 | + | ctx: ctx, | |
| 202 | + | uiScale: uiScale, | |
| 203 | + | sizeCfg: sizeCfg, | |
| 199 | 204 | } | |
| 200 | 205 | o.idleCoalescer = mainloop.NewCoalescer(func(fn func()) { | |
| 201 | 206 | var cb glib.SourceFunc = func(uintptr) bool { | |
| @@ -336,6 +341,109 @@ func shouldApplyEmptyResultsState(rowCount int, visible bool) bool { | |||
| 336 | 341 | return !visible || rowCount <= 0 | |
| 337 | 342 | } | |
| 338 | 343 | ||
| 344 | + | type initialBehaviorBadgeStateInfo struct { | |
| 345 | + | visible bool | |
| 346 | + | label string | |
| 347 | + | tooltip string | |
| 348 | + | nextBehavior entity.OmniboxInitialBehavior | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | func initialBehaviorBadgeState(initialBehavior entity.OmniboxInitialBehavior) initialBehaviorBadgeStateInfo { | |
| 352 | + | switch initialBehavior { | |
| 353 | + | case entity.OmniboxInitialBehaviorRecent: | |
| 354 | + | return initialBehaviorBadgeStateInfo{ | |
| 355 | + | visible: true, | |
| 356 | + | label: "Recent", | |
| 357 | + | tooltip: initialBehaviorBadgeTooltip, | |
| 358 | + | nextBehavior: entity.OmniboxInitialBehaviorMostVisited, | |
| 359 | + | } | |
| 360 | + | case entity.OmniboxInitialBehaviorMostVisited: | |
| 361 | + | return initialBehaviorBadgeStateInfo{ | |
| 362 | + | visible: true, | |
| 363 | + | label: "Most used", | |
| 364 | + | tooltip: initialBehaviorBadgeTooltip, | |
| 365 | + | nextBehavior: entity.OmniboxInitialBehaviorRecent, | |
| 366 | + | } | |
| 367 | + | default: | |
| 368 | + | return initialBehaviorBadgeStateInfo{} | |
| 369 | + | } | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | var loadInitialHistoryFn = func(o *Omnibox, token uint64) { | |
| 373 | + | o.loadInitialHistory(token) | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | func shouldRefreshInitialHistoryAfterToggle(viewMode ViewMode, query string, initialBehavior entity.OmniboxInitialBehavior) bool { | |
| 377 | + | if viewMode != ViewModeHistory { | |
| 378 | + | return false | |
| 379 | + | } | |
| 380 | + | if strings.TrimSpace(query) != "" { | |
| 381 | + | return false | |
| 382 | + | } | |
| 383 | + | switch initialBehavior { | |
| 384 | + | case entity.OmniboxInitialBehaviorRecent, entity.OmniboxInitialBehaviorMostVisited: | |
| 385 | + | return true | |
| 386 | + | default: | |
| 387 | + | return false | |
| 388 | + | } | |
| 389 | + | } | |
| 390 | + | ||
| 391 | + | func (o *Omnibox) refreshInitialBehaviorBadge() { | |
| 392 | + | if o.initialBehaviorBadge == nil { | |
| 393 | + | return | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | state := initialBehaviorBadgeState(o.initialBehavior) | |
| 397 | + | o.initialBehaviorBadge.SetVisible(state.visible) | |
| 398 | + | if !state.visible { | |
| 399 | + | return | |
| 400 | + | } | |
| 401 | + | o.initialBehaviorBadge.SetLabel(state.label) | |
| 402 | + | tooltip := state.tooltip | |
| 403 | + | o.initialBehaviorBadge.SetTooltipText(&tooltip) | |
| 404 | + | } | |
| 405 | + | ||
| 406 | + | func (o *Omnibox) toggleInitialBehaviorPreference() bool { | |
| 407 | + | state := initialBehaviorBadgeState(o.initialBehavior) | |
| 408 | + | if state.nextBehavior == "" || o.saveInitialBehaviorFn == nil { | |
| 409 | + | return false | |
| 410 | + | } | |
| 411 | + | ||
| 412 | + | nextBehavior := state.nextBehavior | |
| 413 | + | if err := o.saveInitialBehaviorFn(o.ctx, nextBehavior); err != nil { | |
| 414 | + | if o.onToast != nil { | |
| 415 | + | o.onToast(o.ctx, "Failed to save default history order", ToastError) | |
| 416 | + | } | |
| 417 | + | return false | |
| 418 | + | } | |
| 419 | + | ||
| 420 | + | o.initialBehavior = nextBehavior | |
| 421 | + | o.refreshInitialBehaviorBadge() | |
| 422 | + | ||
| 423 | + | var query string | |
| 424 | + | if o.entry != nil { | |
| 425 | + | query = o.entry.GetText() | |
| 426 | + | } | |
| 427 | + | if shouldRefreshInitialHistoryAfterToggle(o.viewMode, query, o.initialBehavior) && loadInitialHistoryFn != nil { | |
| 428 | + | loadInitialHistoryFn(o, o.searchToken) | |
| 429 | + | } | |
| 430 | + | ||
| 431 | + | return true | |
| 432 | + | } | |
| 433 | + | ||
| 434 | + | func (o *Omnibox) handleEscapeKeyPress() bool { | |
| 435 | + | if o.entry == nil { | |
| 436 | + | return false | |
| 437 | + | } | |
| 438 | + | text := o.entry.GetText() | |
| 439 | + | if text != "" { | |
| 440 | + | o.entry.SetText("") | |
| 441 | + | return true | |
| 442 | + | } | |
| 443 | + | o.Hide(o.ctx) | |
| 444 | + | return true | |
| 445 | + | } | |
| 446 | + | ||
| 339 | 447 | func (o *Omnibox) setResultsContainerState(rowCount int) { | |
| 340 | 448 | if o.scrolledWin == nil { | |
| 341 | 449 | return | |
| @@ -545,6 +653,21 @@ func (o *Omnibox) initHeader() error { | |||
| 545 | 653 | o.favoritesBtn.AddCssClass("omnibox-header-btn") | |
| 546 | 654 | o.favoritesBtn.SetCanFocus(false) | |
| 547 | 655 | ||
| 656 | + | o.initialBehaviorBadge = gtk.NewButtonWithLabel("") | |
| 657 | + | if o.initialBehaviorBadge == nil { | |
| 658 | + | return errNilWidget("initialBehaviorBadge") | |
| 659 | + | } | |
| 660 | + | o.initialBehaviorBadge.AddCssClass("omnibox-header-badge") | |
| 661 | + | o.initialBehaviorBadge.SetCanFocus(false) | |
| 662 | + | o.initialBehaviorBadge.SetFocusOnClick(false) | |
| 663 | + | ||
| 664 | + | initialBehaviorClickCb := func(_ gtk.Button) { | |
| 665 | + | o.toggleInitialBehaviorPreference() | |
| 666 | + | } | |
| 667 | + | o.retainedCallbacks = append(o.retainedCallbacks, initialBehaviorClickCb) | |
| 668 | + | o.initialBehaviorBadge.ConnectClicked(&initialBehaviorClickCb) | |
| 669 | + | o.refreshInitialBehaviorBadge() | |
| 670 | + | ||
| 548 | 671 | historyClickCb := func(_ gtk.Button) { | |
| 549 | 672 | o.setViewMode(ViewModeHistory) | |
| 550 | 673 | } | |
| @@ -657,6 +780,9 @@ func (o *Omnibox) assembleWidgets() { | |||
| 657 | 780 | if endBox != nil { | |
| 658 | 781 | endBox.SetHexpand(true) | |
| 659 | 782 | endBox.SetHalign(gtk.AlignEndValue) | |
| 783 | + | if o.initialBehaviorBadge != nil { | |
| 784 | + | endBox.Append(&o.initialBehaviorBadge.Widget) | |
| 785 | + | } | |
| 660 | 786 | if o.bangBadge != nil { | |
| 661 | 787 | endBox.Append(&o.bangBadge.Widget) | |
| 662 | 788 | } | |
| @@ -735,14 +861,13 @@ func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) b | |||
| 735 | 861 | ctrl := state&gdk.ControlMaskValue != 0 | |
| 736 | 862 | ||
| 737 | 863 | switch keyval { | |
| 738 | - | case uint(gdk.KEY_Escape): | |
| 739 | - | text := o.entry.GetText() | |
| 740 | - | if text != "" { | |
| 741 | - | o.entry.SetText("") | |
| 742 | - | return true | |
| 864 | + | case uint(gdk.KEY_r), uint(gdk.KEY_R): | |
| 865 | + | if ctrl { | |
| 866 | + | return o.toggleInitialBehaviorPreference() | |
| 743 | 867 | } | |
| 744 | - | o.Hide(o.ctx) | |
| 745 | - | return true | |
| 868 | + | ||
| 869 | + | case uint(gdk.KEY_Escape): | |
| 870 | + | return o.handleEscapeKeyPress() | |
| 746 | 871 | ||
| 747 | 872 | case uint(gdk.KEY_l), uint(gdk.KEY_L): | |
| 748 | 873 | // Ctrl+L toggles omnibox (hides when visible) | |
| @@ -1464,18 +1589,19 @@ func (o *Omnibox) loadInitialHistory(token uint64) { | |||
| 1464 | 1589 | ||
| 1465 | 1590 | // Capture effective result limit on the GTK main thread before spawning goroutine | |
| 1466 | 1591 | initialLimit := o.effectiveMaxRows() | |
| 1592 | + | initialBehavior := o.initialBehavior | |
| 1467 | 1593 | ||
| 1468 | 1594 | go func() { | |
| 1469 | 1595 | ctx := o.ctx | |
| 1470 | 1596 | log := logging.FromContext(ctx) | |
| 1471 | 1597 | var suggestions []Suggestion | |
| 1472 | 1598 | ||
| 1473 | - | switch o.initialBehavior { | |
| 1474 | - | case "none": | |
| 1599 | + | switch initialBehavior { | |
| 1600 | + | case entity.OmniboxInitialBehaviorNone: | |
| 1475 | 1601 | o.idleAddUpdateSuggestions(nil, "", token) | |
| 1476 | 1602 | return | |
| 1477 | 1603 | ||
| 1478 | - | case "most_visited", "recent", "": | |
| 1604 | + | case entity.OmniboxInitialBehaviorMostVisited, entity.OmniboxInitialBehaviorRecent, "": | |
| 1479 | 1605 | // Run history fetch and favorite URL fetch in parallel | |
| 1480 | 1606 | type historyResult struct { | |
| 1481 | 1607 | results []*entity.HistoryEntry | |
| @@ -1489,7 +1615,7 @@ func (o *Omnibox) loadInitialHistory(token uint64) { | |||
| 1489 | 1615 | results []*entity.HistoryEntry | |
| 1490 | 1616 | err error | |
| 1491 | 1617 | ) | |
| 1492 | - | if o.initialBehavior == "most_visited" { | |
| 1618 | + | if initialBehavior == entity.OmniboxInitialBehaviorMostVisited { | |
| 1493 | 1619 | results, err = o.historyUC.GetMostVisited(ctx, 0) | |
| 1494 | 1620 | if err == nil && len(results) > initialLimit { | |
| 1495 | 1621 | results = results[:initialLimit] | |
| @@ -27,10 +27,11 @@ import ( | |||||
| 27 | ) | 27 | ) | ||
| 28 | 28 | ||||
| 29 | const ( | 29 | const ( | ||
| 30 | - | debounceDelayMs = 50 | 30 | + | debounceDelayMs = 50 |
| 31 | - | endBoxSpacing = 6 | 31 | + | endBoxSpacing = 6 |
| 32 | - | defaultOmniboxPlaceholder = "Search history or enter URL… (! lists bangs)" | 32 | + | defaultOmniboxPlaceholder = "Search history or enter URL… (! lists bangs)" |
| 33 | - | minGhostInputLength = 1 | 33 | + | minGhostInputLength = 1 |
| 34 | + | initialBehaviorBadgeTooltip = "Toggle default history order (Ctrl+R)" | |||
| 34 | ) | 35 | ) | ||
| 35 | 36 | ||||
| 36 | type favoriteRowIndicatorUpdate struct { | 37 | type favoriteRowIndicatorUpdate struct { | ||
| @@ -73,16 +74,17 @@ type Favorite struct { | |||||
| 73 | // Omnibox is the native GTK4 address bar / command palette. | 74 | // Omnibox is the native GTK4 address bar / command palette. | ||
| 74 | type Omnibox struct { | 75 | type Omnibox struct { | ||
| 75 | // GTK widgets | 76 | // GTK widgets | ||
| 76 | - | outerBox *gtk.Box // Outer container for positioning | 77 | + | outerBox *gtk.Box // Outer container for positioning |
| 77 | - | mainBox *gtk.Box // Main content box | 78 | + | mainBox *gtk.Box // Main content box |
| 78 | - | headerBox *gtk.Box | 79 | + | headerBox *gtk.Box |
| 79 | - | historyBtn *gtk.Button | 80 | + | initialBehaviorBadge *gtk.Button |
| 80 | - | favoritesBtn *gtk.Button | 81 | + | historyBtn *gtk.Button |
| 81 | - | zoomLabel *gtk.Label | 82 | + | favoritesBtn *gtk.Button |
| 82 | - | bangBadge *gtk.Label | 83 | + | zoomLabel *gtk.Label |
| 83 | - | entry *gtk.SearchEntry | 84 | + | bangBadge *gtk.Label |
| 84 | - | scrolledWin *gtk.ScrolledWindow | 85 | + | entry *gtk.SearchEntry |
| 85 | - | listBox *gtk.ListBox | 86 | + | scrolledWin *gtk.ScrolledWindow |
| 87 | + | listBox *gtk.ListBox | |||
| 86 | 88 | ||||
| 87 | // Parent overlay reference for sizing (set via SetParentOverlay) | 89 | // Parent overlay reference for sizing (set via SetParentOverlay) | ||
| 88 | parentOverlay layout.OverlayWidget | 90 | parentOverlay layout.OverlayWidget | ||
| @@ -106,14 +108,15 @@ type Omnibox struct { | |||||
| 106 | isSettingGhost bool // Guard flag: skip onEntryChanged during programmatic SetText | 108 | isSettingGhost bool // Guard flag: skip onEntryChanged during programmatic SetText | ||
| 107 | 109 | ||||
| 108 | // Dependencies | 110 | // Dependencies | ||
| 109 | - | historyUC *usecase.SearchHistoryUseCase | 111 | + | historyUC *usecase.SearchHistoryUseCase |
| 110 | - | favoritesUC *usecase.ManageFavoritesUseCase | 112 | + | favoritesUC *usecase.ManageFavoritesUseCase |
| 111 | - | faviconAdapter *adapter.FaviconAdapter | 113 | + | faviconAdapter *adapter.FaviconAdapter |
| 112 | - | copyURLUC *usecase.CopyURLUseCase | 114 | + | copyURLUC *usecase.CopyURLUseCase |
| 113 | - | shortcutsUC *usecase.SearchShortcutsUseCase | 115 | + | shortcutsUC *usecase.SearchShortcutsUseCase |
| 114 | - | defaultSearch string | 116 | + | defaultSearch string |
| 115 | - | initialBehavior string | 117 | + | initialBehavior entity.OmniboxInitialBehavior |
| 116 | - | ctx context.Context | 118 | + | saveInitialBehaviorFn func(context.Context, entity.OmniboxInitialBehavior) error |
| 119 | + | ctx context.Context | |||
| 117 | 120 | ||||
| 118 | // Callbacks | 121 | // Callbacks | ||
| 119 | onNavigate func(url string) | 122 | onNavigate func(url string) | ||
| @@ -151,21 +154,22 @@ type Omnibox struct { | |||||
| 151 | 154 | ||||
| 152 | // OmniboxConfig holds configuration for creating an Omnibox. | 155 | // OmniboxConfig holds configuration for creating an Omnibox. | ||
| 153 | type OmniboxConfig struct { | 156 | type OmniboxConfig struct { | ||
| 154 | - | HistoryUC *usecase.SearchHistoryUseCase | 157 | + | HistoryUC *usecase.SearchHistoryUseCase |
| 155 | - | FavoritesUC *usecase.ManageFavoritesUseCase | 158 | + | FavoritesUC *usecase.ManageFavoritesUseCase |
| 156 | - | FaviconAdapter *adapter.FaviconAdapter | 159 | + | FaviconAdapter *adapter.FaviconAdapter |
| 157 | - | CopyURLUC *usecase.CopyURLUseCase | 160 | + | CopyURLUC *usecase.CopyURLUseCase |
| 158 | - | ShortcutsUC *usecase.SearchShortcutsUseCase | 161 | + | ShortcutsUC *usecase.SearchShortcutsUseCase |
| 159 | - | DefaultSearch string | 162 | + | DefaultSearch string |
| 160 | - | InitialBehavior string | 163 | + | InitialBehavior entity.OmniboxInitialBehavior |
| 161 | - | UIScale float64 // UI scale for favicon sizing | 164 | + | SaveInitialBehavior func(ctx context.Context, behavior entity.OmniboxInitialBehavior) error |
| 162 | - | OnNavigate func(url string) // Callback when user navigates via omnibox | 165 | + | UIScale float64 // UI scale for favicon sizing |
| 163 | - | OnToast func(ctx context.Context, message string, level ToastLevel) // Callback to show toast notification | 166 | + | OnNavigate func(url string) // Callback when user navigates via omnibox |
| 164 | - | OnFocusIn func(entry *gtk.SearchEntry) // Callback when entry gains focus (for accent picker) | 167 | + | OnToast func(ctx context.Context, message string, level ToastLevel) // Callback to show toast notification |
| 165 | - | OnFocusOut func() // Callback when entry loses focus | 168 | + | OnFocusIn func(entry *gtk.SearchEntry) // Callback when entry gains focus (for accent picker) |
| 166 | - | OnAccentKeyPress func(keyval uint, state gdk.ModifierType) bool // Long-press accent detection | 169 | + | OnFocusOut func() // Callback when entry loses focus |
| 167 | - | OnAccentKeyRelease func(keyval uint) // Key release for accent cancel | 170 | + | OnAccentKeyPress func(keyval uint, state gdk.ModifierType) bool // Long-press accent detection |
| 168 | - | SizeConfig ModalSizeConfig // Optional geometry override for omnibox sizing | 171 | + | OnAccentKeyRelease func(keyval uint) // Key release for accent cancel |
| 172 | + | SizeConfig ModalSizeConfig // Optional geometry override for omnibox sizing | |||
| 169 | } | 173 | } | ||
| 170 | 174 | ||||
| 171 | // NewOmnibox creates a new native GTK4 omnibox widget. | 175 | // NewOmnibox creates a new native GTK4 omnibox widget. | ||
| @@ -181,21 +185,22 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox { | |||||
| 181 | sizeCfg := ResolveModalSizeConfig(cfg.SizeConfig, OmniboxSizeDefaults) | 185 | sizeCfg := ResolveModalSizeConfig(cfg.SizeConfig, OmniboxSizeDefaults) | ||
| 182 | 186 | ||||
| 183 | o := &Omnibox{ | 187 | o := &Omnibox{ | ||
| 184 | - | viewMode: ViewModeHistory, | 188 | + | viewMode: ViewModeHistory, |
| 185 | - | selectedIndex: -1, | 189 | + | selectedIndex: -1, |
| 186 | - | historyUC: cfg.HistoryUC, | 190 | + | historyUC: cfg.HistoryUC, |
| 187 | - | favoritesUC: cfg.FavoritesUC, | 191 | + | favoritesUC: cfg.FavoritesUC, |
| 188 | - | faviconAdapter: cfg.FaviconAdapter, | 192 | + | faviconAdapter: cfg.FaviconAdapter, |
| 189 | - | copyURLUC: cfg.CopyURLUC, | 193 | + | copyURLUC: cfg.CopyURLUC, |
| 190 | - | shortcutsUC: cfg.ShortcutsUC, | 194 | + | shortcutsUC: cfg.ShortcutsUC, |
| 191 | - | defaultSearch: cfg.DefaultSearch, | 195 | + | defaultSearch: cfg.DefaultSearch, |
| 192 | - | initialBehavior: cfg.InitialBehavior, | 196 | + | initialBehavior: cfg.InitialBehavior, |
| 193 | - | onToast: cfg.OnToast, | 197 | + | saveInitialBehaviorFn: cfg.SaveInitialBehavior, |
| 194 | - | onAccentKeyPress: cfg.OnAccentKeyPress, | 198 | + | onToast: cfg.OnToast, |
| 195 | - | onAccentKeyRelease: cfg.OnAccentKeyRelease, | 199 | + | onAccentKeyPress: cfg.OnAccentKeyPress, |
| 196 | - | ctx: ctx, | 200 | + | onAccentKeyRelease: cfg.OnAccentKeyRelease, |
| 197 | - | uiScale: uiScale, | 201 | + | ctx: ctx, |
| 198 | - | sizeCfg: sizeCfg, | 202 | + | uiScale: uiScale, |
| 203 | + | sizeCfg: sizeCfg, | |||
| 199 | } | 204 | } | ||
| 200 | o.idleCoalescer = mainloop.NewCoalescer(func(fn func()) { | 205 | o.idleCoalescer = mainloop.NewCoalescer(func(fn func()) { | ||
| 201 | var cb glib.SourceFunc = func(uintptr) bool { | 206 | var cb glib.SourceFunc = func(uintptr) bool { | ||
| @@ -336,6 +341,109 @@ func shouldApplyEmptyResultsState(rowCount int, visible bool) bool { | |||||
| 336 | return !visible || rowCount <= 0 | 341 | return !visible || rowCount <= 0 | ||
| 337 | } | 342 | } | ||
| 338 | 343 | ||||
| 344 | + | type initialBehaviorBadgeStateInfo struct { | |||
| 345 | + | visible bool | |||
| 346 | + | label string | |||
| 347 | + | tooltip string | |||
| 348 | + | nextBehavior entity.OmniboxInitialBehavior | |||
| 349 | + | } | |||
| 350 | + | ||||
| 351 | + | func initialBehaviorBadgeState(initialBehavior entity.OmniboxInitialBehavior) initialBehaviorBadgeStateInfo { | |||
| 352 | + | switch initialBehavior { | |||
| 353 | + | case entity.OmniboxInitialBehaviorRecent: | |||
| 354 | + | return initialBehaviorBadgeStateInfo{ | |||
| 355 | + | visible: true, | |||
| 356 | + | label: "Recent", | |||
| 357 | + | tooltip: initialBehaviorBadgeTooltip, | |||
| 358 | + | nextBehavior: entity.OmniboxInitialBehaviorMostVisited, | |||
| 359 | + | } | |||
| 360 | + | case entity.OmniboxInitialBehaviorMostVisited: | |||
| 361 | + | return initialBehaviorBadgeStateInfo{ | |||
| 362 | + | visible: true, | |||
| 363 | + | label: "Most used", | |||
| 364 | + | tooltip: initialBehaviorBadgeTooltip, | |||
| 365 | + | nextBehavior: entity.OmniboxInitialBehaviorRecent, | |||
| 366 | + | } | |||
| 367 | + | default: | |||
| 368 | + | return initialBehaviorBadgeStateInfo{} | |||
| 369 | + | } | |||
| 370 | + | } | |||
| 371 | + | ||||
| 372 | + | var loadInitialHistoryFn = func(o *Omnibox, token uint64) { | |||
| 373 | + | o.loadInitialHistory(token) | |||
| 374 | + | } | |||
| 375 | + | ||||
| 376 | + | func shouldRefreshInitialHistoryAfterToggle(viewMode ViewMode, query string, initialBehavior entity.OmniboxInitialBehavior) bool { | |||
| 377 | + | if viewMode != ViewModeHistory { | |||
| 378 | + | return false | |||
| 379 | + | } | |||
| 380 | + | if strings.TrimSpace(query) != "" { | |||
| 381 | + | return false | |||
| 382 | + | } | |||
| 383 | + | switch initialBehavior { | |||
| 384 | + | case entity.OmniboxInitialBehaviorRecent, entity.OmniboxInitialBehaviorMostVisited: | |||
| 385 | + | return true | |||
| 386 | + | default: | |||
| 387 | + | return false | |||
| 388 | + | } | |||
| 389 | + | } | |||
| 390 | + | ||||
| 391 | + | func (o *Omnibox) refreshInitialBehaviorBadge() { | |||
| 392 | + | if o.initialBehaviorBadge == nil { | |||
| 393 | + | return | |||
| 394 | + | } | |||
| 395 | + | ||||
| 396 | + | state := initialBehaviorBadgeState(o.initialBehavior) | |||
| 397 | + | o.initialBehaviorBadge.SetVisible(state.visible) | |||
| 398 | + | if !state.visible { | |||
| 399 | + | return | |||
| 400 | + | } | |||
| 401 | + | o.initialBehaviorBadge.SetLabel(state.label) | |||
| 402 | + | tooltip := state.tooltip | |||
| 403 | + | o.initialBehaviorBadge.SetTooltipText(&tooltip) | |||
| 404 | + | } | |||
| 405 | + | ||||
| 406 | + | func (o *Omnibox) toggleInitialBehaviorPreference() bool { | |||
| 407 | + | state := initialBehaviorBadgeState(o.initialBehavior) | |||
| 408 | + | if state.nextBehavior == "" || o.saveInitialBehaviorFn == nil { | |||
| 409 | + | return false | |||
| 410 | + | } | |||
| 411 | + | ||||
| 412 | + | nextBehavior := state.nextBehavior | |||
| 413 | + | if err := o.saveInitialBehaviorFn(o.ctx, nextBehavior); err != nil { | |||
| 414 | + | if o.onToast != nil { | |||
| 415 | + | o.onToast(o.ctx, "Failed to save default history order", ToastError) | |||
| 416 | + | } | |||
| 417 | + | return false | |||
| 418 | + | } | |||
| 419 | + | ||||
| 420 | + | o.initialBehavior = nextBehavior | |||
| 421 | + | o.refreshInitialBehaviorBadge() | |||
| 422 | + | ||||
| 423 | + | var query string | |||
| 424 | + | if o.entry != nil { | |||
| 425 | + | query = o.entry.GetText() | |||
| 426 | + | } | |||
| 427 | + | if shouldRefreshInitialHistoryAfterToggle(o.viewMode, query, o.initialBehavior) && loadInitialHistoryFn != nil { | |||
| 428 | + | loadInitialHistoryFn(o, o.searchToken) | |||
| 429 | + | } | |||
| 430 | + | ||||
| 431 | + | return true | |||
| 432 | + | } | |||
| 433 | + | ||||
| 434 | + | func (o *Omnibox) handleEscapeKeyPress() bool { | |||
| 435 | + | if o.entry == nil { | |||
| 436 | + | return false | |||
| 437 | + | } | |||
| 438 | + | text := o.entry.GetText() | |||
| 439 | + | if text != "" { | |||
| 440 | + | o.entry.SetText("") | |||
| 441 | + | return true | |||
| 442 | + | } | |||
| 443 | + | o.Hide(o.ctx) | |||
| 444 | + | return true | |||
| 445 | + | } | |||
| 446 | + | ||||
| 339 | func (o *Omnibox) setResultsContainerState(rowCount int) { | 447 | func (o *Omnibox) setResultsContainerState(rowCount int) { | ||
| 340 | if o.scrolledWin == nil { | 448 | if o.scrolledWin == nil { | ||
| 341 | return | 449 | return | ||
| @@ -545,6 +653,21 @@ func (o *Omnibox) initHeader() error { | |||||
| 545 | o.favoritesBtn.AddCssClass("omnibox-header-btn") | 653 | o.favoritesBtn.AddCssClass("omnibox-header-btn") | ||
| 546 | o.favoritesBtn.SetCanFocus(false) | 654 | o.favoritesBtn.SetCanFocus(false) | ||
| 547 | 655 | ||||
| 656 | + | o.initialBehaviorBadge = gtk.NewButtonWithLabel("") | |||
| 657 | + | if o.initialBehaviorBadge == nil { | |||
| 658 | + | return errNilWidget("initialBehaviorBadge") | |||
| 659 | + | } | |||
| 660 | + | o.initialBehaviorBadge.AddCssClass("omnibox-header-badge") | |||
| 661 | + | o.initialBehaviorBadge.SetCanFocus(false) | |||
| 662 | + | o.initialBehaviorBadge.SetFocusOnClick(false) | |||
| 663 | + | ||||
| 664 | + | initialBehaviorClickCb := func(_ gtk.Button) { | |||
| 665 | + | o.toggleInitialBehaviorPreference() | |||
| 666 | + | } | |||
| 667 | + | o.retainedCallbacks = append(o.retainedCallbacks, initialBehaviorClickCb) | |||
| 668 | + | o.initialBehaviorBadge.ConnectClicked(&initialBehaviorClickCb) | |||
| 669 | + | o.refreshInitialBehaviorBadge() | |||
| 670 | + | ||||
| 548 | historyClickCb := func(_ gtk.Button) { | 671 | historyClickCb := func(_ gtk.Button) { | ||
| 549 | o.setViewMode(ViewModeHistory) | 672 | o.setViewMode(ViewModeHistory) | ||
| 550 | } | 673 | } | ||
| @@ -657,6 +780,9 @@ func (o *Omnibox) assembleWidgets() { | |||||
| 657 | if endBox != nil { | 780 | if endBox != nil { | ||
| 658 | endBox.SetHexpand(true) | 781 | endBox.SetHexpand(true) | ||
| 659 | endBox.SetHalign(gtk.AlignEndValue) | 782 | endBox.SetHalign(gtk.AlignEndValue) | ||
| 783 | + | if o.initialBehaviorBadge != nil { | |||
| 784 | + | endBox.Append(&o.initialBehaviorBadge.Widget) | |||
| 785 | + | } | |||
| 660 | if o.bangBadge != nil { | 786 | if o.bangBadge != nil { | ||
| 661 | endBox.Append(&o.bangBadge.Widget) | 787 | endBox.Append(&o.bangBadge.Widget) | ||
| 662 | } | 788 | } | ||
| @@ -735,14 +861,13 @@ func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) b | |||||
| 735 | ctrl := state&gdk.ControlMaskValue != 0 | 861 | ctrl := state&gdk.ControlMaskValue != 0 | ||
| 736 | 862 | ||||
| 737 | switch keyval { | 863 | switch keyval { | ||
| 738 | - | case uint(gdk.KEY_Escape): | 864 | + | case uint(gdk.KEY_r), uint(gdk.KEY_R): |
| 739 | - | text := o.entry.GetText() | 865 | + | if ctrl { |
| 740 | - | if text != "" { | 866 | + | return o.toggleInitialBehaviorPreference() |
| 741 | - | o.entry.SetText("") | |||
| 742 | - | return true | |||
| 743 | } | 867 | } | ||
| 744 | - | o.Hide(o.ctx) | 868 | + | |
| 745 | - | return true | 869 | + | case uint(gdk.KEY_Escape): |
| 870 | + | return o.handleEscapeKeyPress() | |||
| 746 | 871 | ||||
| 747 | case uint(gdk.KEY_l), uint(gdk.KEY_L): | 872 | case uint(gdk.KEY_l), uint(gdk.KEY_L): | ||
| 748 | // Ctrl+L toggles omnibox (hides when visible) | 873 | // Ctrl+L toggles omnibox (hides when visible) | ||
| @@ -1464,18 +1589,19 @@ func (o *Omnibox) loadInitialHistory(token uint64) { | |||||
| 1464 | 1589 | ||||
| 1465 | // Capture effective result limit on the GTK main thread before spawning goroutine | 1590 | // Capture effective result limit on the GTK main thread before spawning goroutine | ||
| 1466 | initialLimit := o.effectiveMaxRows() | 1591 | initialLimit := o.effectiveMaxRows() | ||
| 1592 | + | initialBehavior := o.initialBehavior | |||
| 1467 | 1593 | ||||
| 1468 | go func() { | 1594 | go func() { | ||
| 1469 | ctx := o.ctx | 1595 | ctx := o.ctx | ||
| 1470 | log := logging.FromContext(ctx) | 1596 | log := logging.FromContext(ctx) | ||
| 1471 | var suggestions []Suggestion | 1597 | var suggestions []Suggestion | ||
| 1472 | 1598 | ||||
| 1473 | - | switch o.initialBehavior { | 1599 | + | switch initialBehavior { |
| 1474 | - | case "none": | 1600 | + | case entity.OmniboxInitialBehaviorNone: |
| 1475 | o.idleAddUpdateSuggestions(nil, "", token) | 1601 | o.idleAddUpdateSuggestions(nil, "", token) | ||
| 1476 | return | 1602 | return | ||
| 1477 | 1603 | ||||
| 1478 | - | case "most_visited", "recent", "": | 1604 | + | case entity.OmniboxInitialBehaviorMostVisited, entity.OmniboxInitialBehaviorRecent, "": |
| 1479 | // Run history fetch and favorite URL fetch in parallel | 1605 | // Run history fetch and favorite URL fetch in parallel | ||
| 1480 | type historyResult struct { | 1606 | type historyResult struct { | ||
| 1481 | results []*entity.HistoryEntry | 1607 | results []*entity.HistoryEntry | ||
| @@ -1489,7 +1615,7 @@ func (o *Omnibox) loadInitialHistory(token uint64) { | |||||
| 1489 | results []*entity.HistoryEntry | 1615 | results []*entity.HistoryEntry | ||
| 1490 | err error | 1616 | err error | ||
| 1491 | ) | 1617 | ) | ||
| 1492 | - | if o.initialBehavior == "most_visited" { | 1618 | + | if initialBehavior == entity.OmniboxInitialBehaviorMostVisited { |
| 1493 | results, err = o.historyUC.GetMostVisited(ctx, 0) | 1619 | results, err = o.historyUC.GetMostVisited(ctx, 0) | ||
| 1494 | if err == nil && len(results) > initialLimit { | 1620 | if err == nil && len(results) > initialLimit { | ||
| 1495 | results = results[:initialLimit] | 1621 | results = results[:initialLimit] | ||
A internal/ui/component/omnibox_initial_behavior_test.go +180 -0
| @@ -0,0 +1,180 @@ | |||
| 1 | + | package component | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | appusecase "github.com/bnema/dumber/internal/application/usecase" | |
| 10 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 11 | + | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | |
| 12 | + | "github.com/stretchr/testify/assert" | |
| 13 | + | "github.com/stretchr/testify/mock" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestOmniboxInitialBehaviorBadgeState(t *testing.T) { | |
| 17 | + | tests := []struct { | |
| 18 | + | name string | |
| 19 | + | behavior entity.OmniboxInitialBehavior | |
| 20 | + | wantVisible bool | |
| 21 | + | wantLabel string | |
| 22 | + | wantTooltip string | |
| 23 | + | wantNext entity.OmniboxInitialBehavior | |
| 24 | + | }{ | |
| 25 | + | { | |
| 26 | + | name: "recent", | |
| 27 | + | behavior: entity.OmniboxInitialBehaviorRecent, | |
| 28 | + | wantVisible: true, | |
| 29 | + | wantLabel: "Recent", | |
| 30 | + | wantTooltip: "Toggle default history order (Ctrl+R)", | |
| 31 | + | wantNext: entity.OmniboxInitialBehaviorMostVisited, | |
| 32 | + | }, | |
| 33 | + | { | |
| 34 | + | name: "most visited", | |
| 35 | + | behavior: entity.OmniboxInitialBehaviorMostVisited, | |
| 36 | + | wantVisible: true, | |
| 37 | + | wantLabel: "Most used", | |
| 38 | + | wantTooltip: "Toggle default history order (Ctrl+R)", | |
| 39 | + | wantNext: entity.OmniboxInitialBehaviorRecent, | |
| 40 | + | }, | |
| 41 | + | { | |
| 42 | + | name: "none", | |
| 43 | + | behavior: entity.OmniboxInitialBehaviorNone, | |
| 44 | + | }, | |
| 45 | + | { | |
| 46 | + | name: "invalid", | |
| 47 | + | behavior: entity.OmniboxInitialBehavior("unsupported"), | |
| 48 | + | }, | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | for _, tt := range tests { | |
| 52 | + | t.Run(tt.name, func(t *testing.T) { | |
| 53 | + | got := initialBehaviorBadgeState(tt.behavior) | |
| 54 | + | assert.Equal(t, tt.wantVisible, got.visible) | |
| 55 | + | assert.Equal(t, tt.wantLabel, got.label) | |
| 56 | + | assert.Equal(t, tt.wantTooltip, got.tooltip) | |
| 57 | + | assert.Equal(t, tt.wantNext, got.nextBehavior) | |
| 58 | + | }) | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func TestShouldRefreshInitialHistoryAfterToggle(t *testing.T) { | |
| 63 | + | tests := []struct { | |
| 64 | + | name string | |
| 65 | + | viewMode ViewMode | |
| 66 | + | query string | |
| 67 | + | behavior entity.OmniboxInitialBehavior | |
| 68 | + | wantRefresh bool | |
| 69 | + | }{ | |
| 70 | + | { | |
| 71 | + | name: "empty history mode", | |
| 72 | + | viewMode: ViewModeHistory, | |
| 73 | + | query: "", | |
| 74 | + | behavior: entity.OmniboxInitialBehaviorRecent, | |
| 75 | + | wantRefresh: true, | |
| 76 | + | }, | |
| 77 | + | { | |
| 78 | + | name: "typed query", | |
| 79 | + | viewMode: ViewModeHistory, | |
| 80 | + | query: "go", | |
| 81 | + | behavior: entity.OmniboxInitialBehaviorRecent, | |
| 82 | + | wantRefresh: false, | |
| 83 | + | }, | |
| 84 | + | { | |
| 85 | + | name: "favorites mode", | |
| 86 | + | viewMode: ViewModeFavorites, | |
| 87 | + | query: "", | |
| 88 | + | behavior: entity.OmniboxInitialBehaviorRecent, | |
| 89 | + | wantRefresh: false, | |
| 90 | + | }, | |
| 91 | + | { | |
| 92 | + | name: "unsupported behavior", | |
| 93 | + | viewMode: ViewModeHistory, | |
| 94 | + | query: "", | |
| 95 | + | behavior: entity.OmniboxInitialBehaviorNone, | |
| 96 | + | wantRefresh: false, | |
| 97 | + | }, | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | for _, tt := range tests { | |
| 101 | + | t.Run(tt.name, func(t *testing.T) { | |
| 102 | + | assert.Equal(t, tt.wantRefresh, shouldRefreshInitialHistoryAfterToggle(tt.viewMode, tt.query, tt.behavior)) | |
| 103 | + | }) | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func TestOmniboxToggleInitialBehaviorPreference_SaveFailureKeepsBehaviorAndShowsToast(t *testing.T) { | |
| 108 | + | o := &Omnibox{initialBehavior: entity.OmniboxInitialBehaviorRecent, ctx: context.Background()} | |
| 109 | + | o.saveInitialBehaviorFn = func(context.Context, entity.OmniboxInitialBehavior) error { | |
| 110 | + | return errors.New("boom") | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | var gotMessage string | |
| 114 | + | var gotLevel ToastLevel | |
| 115 | + | o.onToast = func(_ context.Context, message string, level ToastLevel) { | |
| 116 | + | gotMessage = message | |
| 117 | + | gotLevel = level | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | refreshCalls := 0 | |
| 121 | + | origLoadInitialHistoryFn := loadInitialHistoryFn | |
| 122 | + | loadInitialHistoryFn = func(*Omnibox, uint64) { | |
| 123 | + | refreshCalls++ | |
| 124 | + | } | |
| 125 | + | t.Cleanup(func() { | |
| 126 | + | loadInitialHistoryFn = origLoadInitialHistoryFn | |
| 127 | + | }) | |
| 128 | + | ||
| 129 | + | assert.False(t, o.toggleInitialBehaviorPreference()) | |
| 130 | + | assert.Equal(t, entity.OmniboxInitialBehaviorRecent, o.initialBehavior) | |
| 131 | + | assert.Equal(t, "Failed to save default history order", gotMessage) | |
| 132 | + | assert.Equal(t, ToastError, gotLevel) | |
| 133 | + | assert.Equal(t, 0, refreshCalls) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func TestOmniboxToggleInitialBehaviorPreference_RefreshesEmptyHistory(t *testing.T) { | |
| 137 | + | o := &Omnibox{initialBehavior: entity.OmniboxInitialBehaviorRecent, viewMode: ViewModeHistory, ctx: context.Background()} | |
| 138 | + | o.saveInitialBehaviorFn = func(context.Context, entity.OmniboxInitialBehavior) error { | |
| 139 | + | return nil | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | refreshCalls := 0 | |
| 143 | + | origLoadInitialHistoryFn := loadInitialHistoryFn | |
| 144 | + | loadInitialHistoryFn = func(*Omnibox, uint64) { | |
| 145 | + | refreshCalls++ | |
| 146 | + | } | |
| 147 | + | t.Cleanup(func() { | |
| 148 | + | loadInitialHistoryFn = origLoadInitialHistoryFn | |
| 149 | + | }) | |
| 150 | + | ||
| 151 | + | assert.True(t, o.toggleInitialBehaviorPreference()) | |
| 152 | + | assert.Equal(t, entity.OmniboxInitialBehaviorMostVisited, o.initialBehavior) | |
| 153 | + | assert.Equal(t, 1, refreshCalls) | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func TestOmniboxLoadInitialHistory_UsesCapturedInitialBehavior(t *testing.T) { | |
| 157 | + | repo := repomocks.NewMockHistoryRepository(t) | |
| 158 | + | done := make(chan struct{}) | |
| 159 | + | repo.EXPECT().GetRecent(mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 160 | + | func(context.Context, int, int) ([]*entity.HistoryEntry, error) { | |
| 161 | + | close(done) | |
| 162 | + | return []*entity.HistoryEntry{{URL: "https://example.com"}}, nil | |
| 163 | + | }, | |
| 164 | + | ) | |
| 165 | + | ||
| 166 | + | o := &Omnibox{ | |
| 167 | + | historyUC: appusecase.NewSearchHistoryUseCase(repo), | |
| 168 | + | initialBehavior: entity.OmniboxInitialBehaviorRecent, | |
| 169 | + | ctx: context.Background(), | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | o.loadInitialHistory(1) | |
| 173 | + | o.initialBehavior = entity.OmniboxInitialBehaviorMostVisited | |
| 174 | + | ||
| 175 | + | select { | |
| 176 | + | case <-done: | |
| 177 | + | case <-time.After(2 * time.Second): | |
| 178 | + | t.Fatal("timed out waiting for recent-history load") | |
| 179 | + | } | |
| 180 | + | } | |
| @@ -0,0 +1,180 @@ | |||||
| 1 | + | package component | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | appusecase "github.com/bnema/dumber/internal/application/usecase" | |||
| 10 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 11 | + | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | |||
| 12 | + | "github.com/stretchr/testify/assert" | |||
| 13 | + | "github.com/stretchr/testify/mock" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func TestOmniboxInitialBehaviorBadgeState(t *testing.T) { | |||
| 17 | + | tests := []struct { | |||
| 18 | + | name string | |||
| 19 | + | behavior entity.OmniboxInitialBehavior | |||
| 20 | + | wantVisible bool | |||
| 21 | + | wantLabel string | |||
| 22 | + | wantTooltip string | |||
| 23 | + | wantNext entity.OmniboxInitialBehavior | |||
| 24 | + | }{ | |||
| 25 | + | { | |||
| 26 | + | name: "recent", | |||
| 27 | + | behavior: entity.OmniboxInitialBehaviorRecent, | |||
| 28 | + | wantVisible: true, | |||
| 29 | + | wantLabel: "Recent", | |||
| 30 | + | wantTooltip: "Toggle default history order (Ctrl+R)", | |||
| 31 | + | wantNext: entity.OmniboxInitialBehaviorMostVisited, | |||
| 32 | + | }, | |||
| 33 | + | { | |||
| 34 | + | name: "most visited", | |||
| 35 | + | behavior: entity.OmniboxInitialBehaviorMostVisited, | |||
| 36 | + | wantVisible: true, | |||
| 37 | + | wantLabel: "Most used", | |||
| 38 | + | wantTooltip: "Toggle default history order (Ctrl+R)", | |||
| 39 | + | wantNext: entity.OmniboxInitialBehaviorRecent, | |||
| 40 | + | }, | |||
| 41 | + | { | |||
| 42 | + | name: "none", | |||
| 43 | + | behavior: entity.OmniboxInitialBehaviorNone, | |||
| 44 | + | }, | |||
| 45 | + | { | |||
| 46 | + | name: "invalid", | |||
| 47 | + | behavior: entity.OmniboxInitialBehavior("unsupported"), | |||
| 48 | + | }, | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | for _, tt := range tests { | |||
| 52 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 53 | + | got := initialBehaviorBadgeState(tt.behavior) | |||
| 54 | + | assert.Equal(t, tt.wantVisible, got.visible) | |||
| 55 | + | assert.Equal(t, tt.wantLabel, got.label) | |||
| 56 | + | assert.Equal(t, tt.wantTooltip, got.tooltip) | |||
| 57 | + | assert.Equal(t, tt.wantNext, got.nextBehavior) | |||
| 58 | + | }) | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func TestShouldRefreshInitialHistoryAfterToggle(t *testing.T) { | |||
| 63 | + | tests := []struct { | |||
| 64 | + | name string | |||
| 65 | + | viewMode ViewMode | |||
| 66 | + | query string | |||
| 67 | + | behavior entity.OmniboxInitialBehavior | |||
| 68 | + | wantRefresh bool | |||
| 69 | + | }{ | |||
| 70 | + | { | |||
| 71 | + | name: "empty history mode", | |||
| 72 | + | viewMode: ViewModeHistory, | |||
| 73 | + | query: "", | |||
| 74 | + | behavior: entity.OmniboxInitialBehaviorRecent, | |||
| 75 | + | wantRefresh: true, | |||
| 76 | + | }, | |||
| 77 | + | { | |||
| 78 | + | name: "typed query", | |||
| 79 | + | viewMode: ViewModeHistory, | |||
| 80 | + | query: "go", | |||
| 81 | + | behavior: entity.OmniboxInitialBehaviorRecent, | |||
| 82 | + | wantRefresh: false, | |||
| 83 | + | }, | |||
| 84 | + | { | |||
| 85 | + | name: "favorites mode", | |||
| 86 | + | viewMode: ViewModeFavorites, | |||
| 87 | + | query: "", | |||
| 88 | + | behavior: entity.OmniboxInitialBehaviorRecent, | |||
| 89 | + | wantRefresh: false, | |||
| 90 | + | }, | |||
| 91 | + | { | |||
| 92 | + | name: "unsupported behavior", | |||
| 93 | + | viewMode: ViewModeHistory, | |||
| 94 | + | query: "", | |||
| 95 | + | behavior: entity.OmniboxInitialBehaviorNone, | |||
| 96 | + | wantRefresh: false, | |||
| 97 | + | }, | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | for _, tt := range tests { | |||
| 101 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 102 | + | assert.Equal(t, tt.wantRefresh, shouldRefreshInitialHistoryAfterToggle(tt.viewMode, tt.query, tt.behavior)) | |||
| 103 | + | }) | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func TestOmniboxToggleInitialBehaviorPreference_SaveFailureKeepsBehaviorAndShowsToast(t *testing.T) { | |||
| 108 | + | o := &Omnibox{initialBehavior: entity.OmniboxInitialBehaviorRecent, ctx: context.Background()} | |||
| 109 | + | o.saveInitialBehaviorFn = func(context.Context, entity.OmniboxInitialBehavior) error { | |||
| 110 | + | return errors.New("boom") | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | var gotMessage string | |||
| 114 | + | var gotLevel ToastLevel | |||
| 115 | + | o.onToast = func(_ context.Context, message string, level ToastLevel) { | |||
| 116 | + | gotMessage = message | |||
| 117 | + | gotLevel = level | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | refreshCalls := 0 | |||
| 121 | + | origLoadInitialHistoryFn := loadInitialHistoryFn | |||
| 122 | + | loadInitialHistoryFn = func(*Omnibox, uint64) { | |||
| 123 | + | refreshCalls++ | |||
| 124 | + | } | |||
| 125 | + | t.Cleanup(func() { | |||
| 126 | + | loadInitialHistoryFn = origLoadInitialHistoryFn | |||
| 127 | + | }) | |||
| 128 | + | ||||
| 129 | + | assert.False(t, o.toggleInitialBehaviorPreference()) | |||
| 130 | + | assert.Equal(t, entity.OmniboxInitialBehaviorRecent, o.initialBehavior) | |||
| 131 | + | assert.Equal(t, "Failed to save default history order", gotMessage) | |||
| 132 | + | assert.Equal(t, ToastError, gotLevel) | |||
| 133 | + | assert.Equal(t, 0, refreshCalls) | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func TestOmniboxToggleInitialBehaviorPreference_RefreshesEmptyHistory(t *testing.T) { | |||
| 137 | + | o := &Omnibox{initialBehavior: entity.OmniboxInitialBehaviorRecent, viewMode: ViewModeHistory, ctx: context.Background()} | |||
| 138 | + | o.saveInitialBehaviorFn = func(context.Context, entity.OmniboxInitialBehavior) error { | |||
| 139 | + | return nil | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | refreshCalls := 0 | |||
| 143 | + | origLoadInitialHistoryFn := loadInitialHistoryFn | |||
| 144 | + | loadInitialHistoryFn = func(*Omnibox, uint64) { | |||
| 145 | + | refreshCalls++ | |||
| 146 | + | } | |||
| 147 | + | t.Cleanup(func() { | |||
| 148 | + | loadInitialHistoryFn = origLoadInitialHistoryFn | |||
| 149 | + | }) | |||
| 150 | + | ||||
| 151 | + | assert.True(t, o.toggleInitialBehaviorPreference()) | |||
| 152 | + | assert.Equal(t, entity.OmniboxInitialBehaviorMostVisited, o.initialBehavior) | |||
| 153 | + | assert.Equal(t, 1, refreshCalls) | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | func TestOmniboxLoadInitialHistory_UsesCapturedInitialBehavior(t *testing.T) { | |||
| 157 | + | repo := repomocks.NewMockHistoryRepository(t) | |||
| 158 | + | done := make(chan struct{}) | |||
| 159 | + | repo.EXPECT().GetRecent(mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |||
| 160 | + | func(context.Context, int, int) ([]*entity.HistoryEntry, error) { | |||
| 161 | + | close(done) | |||
| 162 | + | return []*entity.HistoryEntry{{URL: "https://example.com"}}, nil | |||
| 163 | + | }, | |||
| 164 | + | ) | |||
| 165 | + | ||||
| 166 | + | o := &Omnibox{ | |||
| 167 | + | historyUC: appusecase.NewSearchHistoryUseCase(repo), | |||
| 168 | + | initialBehavior: entity.OmniboxInitialBehaviorRecent, | |||
| 169 | + | ctx: context.Background(), | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | o.loadInitialHistory(1) | |||
| 173 | + | o.initialBehavior = entity.OmniboxInitialBehaviorMostVisited | |||
| 174 | + | ||||
| 175 | + | select { | |||
| 176 | + | case <-done: | |||
| 177 | + | case <-time.After(2 * time.Second): | |||
| 178 | + | t.Fatal("timed out waiting for recent-history load") | |||
| 179 | + | } | |||
| 180 | + | } | |||
A internal/ui/component/omnibox_initial_behavior_toggle_test.go +59 -0
| @@ -0,0 +1,59 @@ | |||
| 1 | + | package component | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | + | "github.com/bnema/puregotk/v4/gdk" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestHandleKeyPress_CtrlRTriggersInitialBehaviorToggle(t *testing.T) { | |
| 12 | + | o := &Omnibox{initialBehavior: entity.OmniboxInitialBehaviorRecent, viewMode: ViewModeHistory} | |
| 13 | + | saveCalls := 0 | |
| 14 | + | o.saveInitialBehaviorFn = func(context.Context, entity.OmniboxInitialBehavior) error { | |
| 15 | + | saveCalls++ | |
| 16 | + | return nil | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | var refreshCalls int | |
| 20 | + | origLoadInitialHistoryFn := loadInitialHistoryFn | |
| 21 | + | loadInitialHistoryFn = func(*Omnibox, uint64) { | |
| 22 | + | refreshCalls++ | |
| 23 | + | } | |
| 24 | + | defer func() { | |
| 25 | + | loadInitialHistoryFn = origLoadInitialHistoryFn | |
| 26 | + | }() | |
| 27 | + | ||
| 28 | + | if got := o.handleKeyPress(uint(gdk.KEY_r), 0, gdk.ControlMaskValue); !got { | |
| 29 | + | t.Fatalf("handleKeyPress Ctrl+R = false, want true") | |
| 30 | + | } | |
| 31 | + | if got := o.initialBehavior; got != entity.OmniboxInitialBehaviorMostVisited { | |
| 32 | + | t.Fatalf("initialBehavior = %q, want %q", got, entity.OmniboxInitialBehaviorMostVisited) | |
| 33 | + | } | |
| 34 | + | if saveCalls != 1 { | |
| 35 | + | t.Fatalf("saveInitialBehavior calls = %d, want 1", saveCalls) | |
| 36 | + | } | |
| 37 | + | if refreshCalls != 1 { | |
| 38 | + | t.Fatalf("loadInitialHistoryFn calls = %d, want 1", refreshCalls) | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestHandleKeyPress_CtrlRIgnoredWhenBehaviorUnsupported(t *testing.T) { | |
| 43 | + | o := &Omnibox{initialBehavior: entity.OmniboxInitialBehavior("unsupported")} | |
| 44 | + | saveCalls := 0 | |
| 45 | + | o.saveInitialBehaviorFn = func(context.Context, entity.OmniboxInitialBehavior) error { | |
| 46 | + | saveCalls++ | |
| 47 | + | return nil | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | if got := o.handleKeyPress(uint(gdk.KEY_r), 0, gdk.ControlMaskValue); got { | |
| 51 | + | t.Fatalf("handleKeyPress Ctrl+R = true, want false") | |
| 52 | + | } | |
| 53 | + | if got := o.initialBehavior; got != entity.OmniboxInitialBehavior("unsupported") { | |
| 54 | + | t.Fatalf("initialBehavior = %q, want %q", got, entity.OmniboxInitialBehavior("unsupported")) | |
| 55 | + | } | |
| 56 | + | if saveCalls != 0 { | |
| 57 | + | t.Fatalf("saveInitialBehavior calls = %d, want 0", saveCalls) | |
| 58 | + | } | |
| 59 | + | } | |
| @@ -0,0 +1,59 @@ | |||||
| 1 | + | package component | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 8 | + | "github.com/bnema/puregotk/v4/gdk" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestHandleKeyPress_CtrlRTriggersInitialBehaviorToggle(t *testing.T) { | |||
| 12 | + | o := &Omnibox{initialBehavior: entity.OmniboxInitialBehaviorRecent, viewMode: ViewModeHistory} | |||
| 13 | + | saveCalls := 0 | |||
| 14 | + | o.saveInitialBehaviorFn = func(context.Context, entity.OmniboxInitialBehavior) error { | |||
| 15 | + | saveCalls++ | |||
| 16 | + | return nil | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | var refreshCalls int | |||
| 20 | + | origLoadInitialHistoryFn := loadInitialHistoryFn | |||
| 21 | + | loadInitialHistoryFn = func(*Omnibox, uint64) { | |||
| 22 | + | refreshCalls++ | |||
| 23 | + | } | |||
| 24 | + | defer func() { | |||
| 25 | + | loadInitialHistoryFn = origLoadInitialHistoryFn | |||
| 26 | + | }() | |||
| 27 | + | ||||
| 28 | + | if got := o.handleKeyPress(uint(gdk.KEY_r), 0, gdk.ControlMaskValue); !got { | |||
| 29 | + | t.Fatalf("handleKeyPress Ctrl+R = false, want true") | |||
| 30 | + | } | |||
| 31 | + | if got := o.initialBehavior; got != entity.OmniboxInitialBehaviorMostVisited { | |||
| 32 | + | t.Fatalf("initialBehavior = %q, want %q", got, entity.OmniboxInitialBehaviorMostVisited) | |||
| 33 | + | } | |||
| 34 | + | if saveCalls != 1 { | |||
| 35 | + | t.Fatalf("saveInitialBehavior calls = %d, want 1", saveCalls) | |||
| 36 | + | } | |||
| 37 | + | if refreshCalls != 1 { | |||
| 38 | + | t.Fatalf("loadInitialHistoryFn calls = %d, want 1", refreshCalls) | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func TestHandleKeyPress_CtrlRIgnoredWhenBehaviorUnsupported(t *testing.T) { | |||
| 43 | + | o := &Omnibox{initialBehavior: entity.OmniboxInitialBehavior("unsupported")} | |||
| 44 | + | saveCalls := 0 | |||
| 45 | + | o.saveInitialBehaviorFn = func(context.Context, entity.OmniboxInitialBehavior) error { | |||
| 46 | + | saveCalls++ | |||
| 47 | + | return nil | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | if got := o.handleKeyPress(uint(gdk.KEY_r), 0, gdk.ControlMaskValue); got { | |||
| 51 | + | t.Fatalf("handleKeyPress Ctrl+R = true, want false") | |||
| 52 | + | } | |||
| 53 | + | if got := o.initialBehavior; got != entity.OmniboxInitialBehavior("unsupported") { | |||
| 54 | + | t.Fatalf("initialBehavior = %q, want %q", got, entity.OmniboxInitialBehavior("unsupported")) | |||
| 55 | + | } | |||
| 56 | + | if saveCalls != 0 { | |||
| 57 | + | t.Fatalf("saveInitialBehavior calls = %d, want 0", saveCalls) | |||
| 58 | + | } | |||
| 59 | + | } | |||
M internal/ui/deps.go +3 -0
| @@ -120,6 +120,9 @@ func (d *Dependencies) Validate() error { | |||
| 120 | 120 | if d.HandlerDeps.SaveConfig == nil { | |
| 121 | 121 | return ErrMissingDependency("HandlerDeps.SaveConfig") | |
| 122 | 122 | } | |
| 123 | + | if d.HandlerDeps.SaveOmniboxInitialBehavior == nil { | |
| 124 | + | return ErrMissingDependency("HandlerDeps.SaveOmniboxInitialBehavior") | |
| 125 | + | } | |
| 123 | 126 | // Use cases are optional - can be nil if not needed | |
| 124 | 127 | return nil | |
| 125 | 128 | } | |
| @@ -120,6 +120,9 @@ func (d *Dependencies) Validate() error { | |||||
| 120 | if d.HandlerDeps.SaveConfig == nil { | 120 | if d.HandlerDeps.SaveConfig == nil { | ||
| 121 | return ErrMissingDependency("HandlerDeps.SaveConfig") | 121 | return ErrMissingDependency("HandlerDeps.SaveConfig") | ||
| 122 | } | 122 | } | ||
| 123 | + | if d.HandlerDeps.SaveOmniboxInitialBehavior == nil { | |||
| 124 | + | return ErrMissingDependency("HandlerDeps.SaveOmniboxInitialBehavior") | |||
| 125 | + | } | |||
| 123 | // Use cases are optional - can be nil if not needed | 126 | // Use cases are optional - can be nil if not needed | ||
| 124 | return nil | 127 | return nil | ||
| 125 | } | 128 | } | ||