detached a88e65a

Merge branch 'feat/omnibox-default-order-toggle'

commit
a88e65a9702afeb7c7274c9c8f069bd76d4745be
author
brice · · 2026-04-12 04:32 UTC
merge of
6701101 + 1b3c460 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
24 files · +860 -96
M .mockery.yaml +1 -0
@@ -29,6 +29,7 @@ packages:
2929 FontDetector: {}
3030 KeybindingsProvider: {}
3131 KeybindingsSaver: {}
32+ OmniboxPreferencesSaver: {}
3233 DownloadResponse: {}
3334 PermissionDialogPresenter: {}
3435 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 @@
11 package port
22
3-import "context"
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+)
48
59 // AccentKeyHandler receives accent/dead-key events from the WebView JS bridge.
610 type AccentKeyHandler interface {
@@ -39,9 +43,10 @@ type AutoCopyConfig interface {
3943 // A struct is used here to avoid reverse dependencies between the bootstrap and
4044 // UI layers, which both need to reference this shared dependency container.
4145 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
4752 }
@@ -1,6 +1,10 @@
1 package port1 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 and43 // 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) error46+ SaveConfig func(context.Context, WebUIConfig) error
43- KeybindingsGetter KeybindingsGetter47+ SaveOmniboxInitialBehavior func(context.Context, entity.OmniboxInitialBehavior) error
44- KeybindingSetter KeybindingSetter48+ KeybindingsGetter KeybindingsGetter
45- KeybindingResetter KeybindingResetter49+ KeybindingSetter KeybindingSetter
46- AllKeybindingsResetter AllKeybindingsResetter50+ 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
83208320 return _c
83218321 }
83228322
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+
83238407 // 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.
83248408 // The first argument is typically a *testing.T value.
83258409 func NewMockPermissionDialogPresenter(t interface {
@@ -8320,6 +8320,90 @@ func (_c *MockNotification_ShowZoom_Call) RunAndReturn(run func(ctx context.Cont
8320 return _c8320 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) {
2020
2121 gateway := config.NewKeybindingsGateway(mgr)
2222 saveUC := usecase.NewSaveWebUIConfigUseCase(config.NewWebUIConfigGateway(mgr))
23+ omniboxSaveUC := usecase.NewSaveOmniboxInitialBehaviorUseCase(config.NewOmniboxPreferencesGateway(mgr))
2324
2425 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),
3032 }, nil
3133 }
@@ -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 }, nil32 }, nil
31 }33 }
M internal/domain/entity/config_types.go +9 -0
@@ -157,6 +157,15 @@ const (
157157 PopupBehaviorWindowed PopupBehavior = "windowed"
158158 )
159159
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+
160169 // PopupBehaviorConfig controls how popup windows are handled.
161170 type PopupBehaviorConfig struct {
162171 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 (
1919 defaultNewPaneURL = "about:blank"
2020
2121 // Omnibox defaults
22- defaultOmniboxInitialBehavior = "recent"
22+ defaultOmniboxInitialBehavior = OmniboxInitialBehaviorRecent
2323 defaultOmniboxAutoOpenOnNewPane = false
2424
2525 // Workspace defaults
@@ -19,7 +19,7 @@ const (
19 defaultNewPaneURL = "about:blank"19 defaultNewPaneURL = "about:blank"
20 20
21 // Omnibox defaults21 // Omnibox defaults
22- defaultOmniboxInitialBehavior = "recent"22+ defaultOmniboxInitialBehavior = OmniboxInitialBehaviorRecent
23 defaultOmniboxAutoOpenOnNewPane = false23 defaultOmniboxAutoOpenOnNewPane = false
24 24
25 // Workspace defaults25 // 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 +60 -0
@@ -0,0 +1,60 @@
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+ setTestManagerDefaultSearchEngine(t, mgr, "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+}
51+
52+func setTestManagerDefaultSearchEngine(t *testing.T, mgr *Manager, value string) {
53+ t.Helper()
54+
55+ mgr.mu.Lock()
56+ defer mgr.mu.Unlock()
57+
58+ require.NotNil(t, mgr.config)
59+ mgr.config.DefaultSearchEngine = value
60+}
@@ -0,0 +1,60 @@
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+ setTestManagerDefaultSearchEngine(t, mgr, "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+}
51+
52+func setTestManagerDefaultSearchEngine(t *testing.T, mgr *Manager, value string) {
53+ t.Helper()
54+
55+ mgr.mu.Lock()
56+ defer mgr.mu.Unlock()
57+
58+ require.NotNil(t, mgr.config)
59+ mgr.config.DefaultSearchEngine = value
60+}
M internal/infrastructure/config/profile.go +1 -1
@@ -284,7 +284,7 @@ func resolveCustomProfile(cfg *PerformanceConfig) ResolvedPerformanceSettings {
284284 // fields are set to non-default values. Used for validation.
285285 func HasCustomPerformanceFields(cfg *PerformanceConfig) bool {
286286 return cfg.SkiaCPUPaintingThreads != 0 ||
287- cfg.SkiaGPUPaintingThreads != -1 ||
287+ (cfg.SkiaGPUPaintingThreads != -1 && cfg.SkiaGPUPaintingThreads != 0) ||
288288 cfg.SkiaEnableCPURendering ||
289289 cfg.WebProcessMemoryLimitMB != 0 ||
290290 cfg.WebProcessMemoryPollIntervalSec != 0 ||
@@ -284,7 +284,7 @@ func resolveCustomProfile(cfg *PerformanceConfig) ResolvedPerformanceSettings {
284 // fields are set to non-default values. Used for validation.284 // fields are set to non-default values. Used for validation.
285 func HasCustomPerformanceFields(cfg *PerformanceConfig) bool {285 func HasCustomPerformanceFields(cfg *PerformanceConfig) bool {
286 return cfg.SkiaCPUPaintingThreads != 0 ||286 return cfg.SkiaCPUPaintingThreads != 0 ||
287- cfg.SkiaGPUPaintingThreads != -1 ||287+ (cfg.SkiaGPUPaintingThreads != -1 && cfg.SkiaGPUPaintingThreads != 0) ||
288 cfg.SkiaEnableCPURendering ||288 cfg.SkiaEnableCPURendering ||
289 cfg.WebProcessMemoryLimitMB != 0 ||289 cfg.WebProcessMemoryLimitMB != 0 ||
290 cfg.WebProcessMemoryPollIntervalSec != 0 ||290 cfg.WebProcessMemoryPollIntervalSec != 0 ||
M internal/infrastructure/config/profile_test.go +7 -0
@@ -267,6 +267,13 @@ func TestHasCustomPerformanceFields(t *testing.T) {
267267 },
268268 expected: false,
269269 },
270+ {
271+ name: "gpu threads zero treated as default",
272+ cfg: PerformanceConfig{
273+ SkiaGPUPaintingThreads: 0,
274+ },
275+ expected: false,
276+ },
270277 {
271278 name: "skia cpu threads set",
272279 cfg: PerformanceConfig{
@@ -267,6 +267,13 @@ func TestHasCustomPerformanceFields(t *testing.T) {
267 },267 },
268 expected: false,268 expected: false,
269 },269 },
270+ {
271+ name: "gpu threads zero treated as default",
272+ cfg: PerformanceConfig{
273+ SkiaGPUPaintingThreads: 0,
274+ },
275+ expected: false,
276+ },
270 {277 {
271 name: "skia cpu threads set",278 name: "skia cpu threads set",
272 cfg: PerformanceConfig{279 cfg: PerformanceConfig{
M internal/infrastructure/config/schema.go +13 -1
@@ -56,6 +56,18 @@ const (
5656 PopupBehaviorWindowed = entity.PopupBehaviorWindowed
5757 )
5858
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+
5971 // PopupBehaviorConfig defines handling for popup windows.
6072 type PopupBehaviorConfig = entity.PopupBehaviorConfig
6173
@@ -346,7 +358,7 @@ type ClipboardConfig struct {
346358 type OmniboxConfig struct {
347359 // InitialBehavior controls what to show when omnibox opens with empty input
348360 // 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"`
350362 // AutoOpenOnNewPane opens the omnibox automatically when a new pane is created.
351363 // Default: false
352364 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.PopupBehaviorWindowed56 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.PopupBehaviorConfig72 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 input359 // 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: false363 // 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 {
663663 {
664664 Key: "omnibox.initial_behavior",
665665 Type: "string",
666- Default: defaults.Omnibox.InitialBehavior,
666+ Default: string(defaults.Omnibox.InitialBehavior),
667667 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,
670674 },
671675 {
672676 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 {
389389
390390 func validateOmnibox(config *Config) []string {
391391 switch config.Omnibox.InitialBehavior {
392- case "recent", "most_visited", "none":
392+ case OmniboxInitialBehaviorRecent, OmniboxInitialBehaviorMostVisited, OmniboxInitialBehaviorNone:
393393 return nil
394394 default:
395395 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,
397400 config.Omnibox.InitialBehavior,
398401 )}
399402 }
@@ -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 nil393 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/infrastructure/config/validation_test.go +9 -0
@@ -88,3 +88,12 @@ func TestValidateConfig_CEFConfig(t *testing.T) {
8888 })
8989 }
9090 }
91+
92+func TestValidateConfig_WebKitDefaultProfileIgnoresZeroGPUThreads(t *testing.T) {
93+ cfg := DefaultConfig()
94+ cfg.Engine.Profile = ProfileDefault
95+ cfg.Engine.WebKit.SkiaGPUPaintingThreads = 0
96+
97+ err := validateConfig(cfg)
98+ require.NoError(t, err)
99+}
@@ -88,3 +88,12 @@ func TestValidateConfig_CEFConfig(t *testing.T) {
88 })88 })
89 }89 }
90 }90 }
91+
92+func TestValidateConfig_WebKitDefaultProfileIgnoresZeroGPUThreads(t *testing.T) {
93+ cfg := DefaultConfig()
94+ cfg.Engine.Profile = ProfileDefault
95+ cfg.Engine.WebKit.SkiaGPUPaintingThreads = 0
96+
97+ err := validateConfig(cfg)
98+ require.NoError(t, err)
99+}
M internal/ui/app.go +15 -14
@@ -795,20 +795,21 @@ func buildOmniboxConfig(
795795 }
796796
797797 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,
812813 }
813814 }
814815
@@ -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 (
2727 )
2828
2929 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)"
3435 )
3536
3637 type favoriteRowIndicatorUpdate struct {
@@ -73,16 +74,17 @@ type Favorite struct {
7374 // Omnibox is the native GTK4 address bar / command palette.
7475 type Omnibox struct {
7576 // 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
8688
8789 // Parent overlay reference for sizing (set via SetParentOverlay)
8890 parentOverlay layout.OverlayWidget
@@ -106,14 +108,15 @@ type Omnibox struct {
106108 isSettingGhost bool // Guard flag: skip onEntryChanged during programmatic SetText
107109
108110 // 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
117120
118121 // Callbacks
119122 onNavigate func(url string)
@@ -151,21 +154,22 @@ type Omnibox struct {
151154
152155 // OmniboxConfig holds configuration for creating an Omnibox.
153156 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
169173 }
170174
171175 // NewOmnibox creates a new native GTK4 omnibox widget.
@@ -181,21 +185,22 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
181185 sizeCfg := ResolveModalSizeConfig(cfg.SizeConfig, OmniboxSizeDefaults)
182186
183187 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,
199204 }
200205 o.idleCoalescer = mainloop.NewCoalescer(func(fn func()) {
201206 var cb glib.SourceFunc = func(uintptr) bool {
@@ -336,6 +341,109 @@ func shouldApplyEmptyResultsState(rowCount int, visible bool) bool {
336341 return !visible || rowCount <= 0
337342 }
338343
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+
339447 func (o *Omnibox) setResultsContainerState(rowCount int) {
340448 if o.scrolledWin == nil {
341449 return
@@ -545,6 +653,21 @@ func (o *Omnibox) initHeader() error {
545653 o.favoritesBtn.AddCssClass("omnibox-header-btn")
546654 o.favoritesBtn.SetCanFocus(false)
547655
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+
548671 historyClickCb := func(_ gtk.Button) {
549672 o.setViewMode(ViewModeHistory)
550673 }
@@ -657,6 +780,9 @@ func (o *Omnibox) assembleWidgets() {
657780 if endBox != nil {
658781 endBox.SetHexpand(true)
659782 endBox.SetHalign(gtk.AlignEndValue)
783+ if o.initialBehaviorBadge != nil {
784+ endBox.Append(&o.initialBehaviorBadge.Widget)
785+ }
660786 if o.bangBadge != nil {
661787 endBox.Append(&o.bangBadge.Widget)
662788 }
@@ -735,14 +861,13 @@ func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) b
735861 ctrl := state&gdk.ControlMaskValue != 0
736862
737863 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()
743867 }
744- o.Hide(o.ctx)
745- return true
868+
869+ case uint(gdk.KEY_Escape):
870+ return o.handleEscapeKeyPress()
746871
747872 case uint(gdk.KEY_l), uint(gdk.KEY_L):
748873 // Ctrl+L toggles omnibox (hides when visible)
@@ -1464,18 +1589,19 @@ func (o *Omnibox) loadInitialHistory(token uint64) {
14641589
14651590 // Capture effective result limit on the GTK main thread before spawning goroutine
14661591 initialLimit := o.effectiveMaxRows()
1592+ initialBehavior := o.initialBehavior
14671593
14681594 go func() {
14691595 ctx := o.ctx
14701596 log := logging.FromContext(ctx)
14711597 var suggestions []Suggestion
14721598
1473- switch o.initialBehavior {
1474- case "none":
1599+ switch initialBehavior {
1600+ case entity.OmniboxInitialBehaviorNone:
14751601 o.idleAddUpdateSuggestions(nil, "", token)
14761602 return
14771603
1478- case "most_visited", "recent", "":
1604+ case entity.OmniboxInitialBehaviorMostVisited, entity.OmniboxInitialBehaviorRecent, "":
14791605 // Run history fetch and favorite URL fetch in parallel
14801606 type historyResult struct {
14811607 results []*entity.HistoryEntry
@@ -1489,7 +1615,7 @@ func (o *Omnibox) loadInitialHistory(token uint64) {
14891615 results []*entity.HistoryEntry
14901616 err error
14911617 )
1492- if o.initialBehavior == "most_visited" {
1618+ if initialBehavior == entity.OmniboxInitialBehaviorMostVisited {
14931619 results, err = o.historyUC.GetMostVisited(ctx, 0)
14941620 if err == nil && len(results) > initialLimit {
14951621 results = results[:initialLimit]
@@ -27,10 +27,11 @@ import (
27 )27 )
28 28
29 const (29 const (
30- debounceDelayMs = 5030+ debounceDelayMs = 50
31- endBoxSpacing = 631+ endBoxSpacing = 6
32- defaultOmniboxPlaceholder = "Search history or enter URL… (! lists bangs)"32+ defaultOmniboxPlaceholder = "Search history or enter URL… (! lists bangs)"
33- minGhostInputLength = 133+ 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 widgets76 // GTK widgets
76- outerBox *gtk.Box // Outer container for positioning77+ outerBox *gtk.Box // Outer container for positioning
77- mainBox *gtk.Box // Main content box78+ mainBox *gtk.Box // Main content box
78- headerBox *gtk.Box79+ headerBox *gtk.Box
79- historyBtn *gtk.Button80+ initialBehaviorBadge *gtk.Button
80- favoritesBtn *gtk.Button81+ historyBtn *gtk.Button
81- zoomLabel *gtk.Label82+ favoritesBtn *gtk.Button
82- bangBadge *gtk.Label83+ zoomLabel *gtk.Label
83- entry *gtk.SearchEntry84+ bangBadge *gtk.Label
84- scrolledWin *gtk.ScrolledWindow85+ entry *gtk.SearchEntry
85- listBox *gtk.ListBox86+ 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.OverlayWidget90 parentOverlay layout.OverlayWidget
@@ -106,14 +108,15 @@ type Omnibox struct {
106 isSettingGhost bool // Guard flag: skip onEntryChanged during programmatic SetText108 isSettingGhost bool // Guard flag: skip onEntryChanged during programmatic SetText
107 109
108 // Dependencies110 // Dependencies
109- historyUC *usecase.SearchHistoryUseCase111+ historyUC *usecase.SearchHistoryUseCase
110- favoritesUC *usecase.ManageFavoritesUseCase112+ favoritesUC *usecase.ManageFavoritesUseCase
111- faviconAdapter *adapter.FaviconAdapter113+ faviconAdapter *adapter.FaviconAdapter
112- copyURLUC *usecase.CopyURLUseCase114+ copyURLUC *usecase.CopyURLUseCase
113- shortcutsUC *usecase.SearchShortcutsUseCase115+ shortcutsUC *usecase.SearchShortcutsUseCase
114- defaultSearch string116+ defaultSearch string
115- initialBehavior string117+ initialBehavior entity.OmniboxInitialBehavior
116- ctx context.Context118+ saveInitialBehaviorFn func(context.Context, entity.OmniboxInitialBehavior) error
119+ ctx context.Context
117 120
118 // Callbacks121 // 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.SearchHistoryUseCase157+ HistoryUC *usecase.SearchHistoryUseCase
155- FavoritesUC *usecase.ManageFavoritesUseCase158+ FavoritesUC *usecase.ManageFavoritesUseCase
156- FaviconAdapter *adapter.FaviconAdapter159+ FaviconAdapter *adapter.FaviconAdapter
157- CopyURLUC *usecase.CopyURLUseCase160+ CopyURLUC *usecase.CopyURLUseCase
158- ShortcutsUC *usecase.SearchShortcutsUseCase161+ ShortcutsUC *usecase.SearchShortcutsUseCase
159- DefaultSearch string162+ DefaultSearch string
160- InitialBehavior string163+ InitialBehavior entity.OmniboxInitialBehavior
161- UIScale float64 // UI scale for favicon sizing164+ SaveInitialBehavior func(ctx context.Context, behavior entity.OmniboxInitialBehavior) error
162- OnNavigate func(url string) // Callback when user navigates via omnibox165+ UIScale float64 // UI scale for favicon sizing
163- OnToast func(ctx context.Context, message string, level ToastLevel) // Callback to show toast notification166+ 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 focus168+ 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 detection169+ OnFocusOut func() // Callback when entry loses focus
167- OnAccentKeyRelease func(keyval uint) // Key release for accent cancel170+ OnAccentKeyPress func(keyval uint, state gdk.ModifierType) bool // Long-press accent detection
168- SizeConfig ModalSizeConfig // Optional geometry override for omnibox sizing171+ 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 <= 0341 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 return449 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 != 0861 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 true869+ 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 goroutine1590 // 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.ctx1595 ctx := o.ctx
1470 log := logging.FromContext(ctx)1596 log := logging.FromContext(ctx)
1471 var suggestions []Suggestion1597 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 return1602 return
1477 1603
1478- case "most_visited", "recent", "":1604+ case entity.OmniboxInitialBehaviorMostVisited, entity.OmniboxInitialBehaviorRecent, "":
1479 // Run history fetch and favorite URL fetch in parallel1605 // Run history fetch and favorite URL fetch in parallel
1480 type historyResult struct {1606 type historyResult struct {
1481 results []*entity.HistoryEntry1607 results []*entity.HistoryEntry
@@ -1489,7 +1615,7 @@ func (o *Omnibox) loadInitialHistory(token uint64) {
1489 results []*entity.HistoryEntry1615 results []*entity.HistoryEntry
1490 err error1616 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+ t.Cleanup(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+ t.Cleanup(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 {
120120 if d.HandlerDeps.SaveConfig == nil {
121121 return ErrMissingDependency("HandlerDeps.SaveConfig")
122122 }
123+ if d.HandlerDeps.SaveOmniboxInitialBehavior == nil {
124+ return ErrMissingDependency("HandlerDeps.SaveOmniboxInitialBehavior")
125+ }
123126 // Use cases are optional - can be nil if not needed
124127 return nil
125128 }
@@ -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 needed126 // Use cases are optional - can be nil if not needed
124 return nil127 return nil
125 }128 }
M internal/ui/theme/css.go +18 -0
@@ -407,6 +407,24 @@ entry.omnibox-entry > text:focus-visible {
407407 font-weight: 600;
408408 }
409409
410+/* Header badge used for omnibox state */
411+.omnibox-header-badge {
412+ background-color: alpha(var(--surface-variant), 0.65);
413+ color: var(--muted);
414+ border: 0.0625em solid alpha(var(--border), 0.85);
415+ border-radius: 0.1875em;
416+ padding: 0.0625em 0.375em;
417+ font-size: 0.6875em;
418+ font-weight: 500;
419+ transition: background-color 100ms ease-in-out, border-color 100ms ease-in-out, color 100ms ease-in-out;
420+}
421+
422+.omnibox-header-badge:hover {
423+ background-color: shade(var(--surface-variant), 1.08);
424+ border-color: var(--border);
425+ color: var(--text);
426+}
427+
410428 /* Zoom indicator in omnibox header */
411429 .omnibox-zoom-indicator {
412430 background-color: alpha(var(--accent), 0.2);
@@ -407,6 +407,24 @@ entry.omnibox-entry > text:focus-visible {
407 font-weight: 600;407 font-weight: 600;
408 }408 }
409 409
410+/* Header badge used for omnibox state */
411+.omnibox-header-badge {
412+ background-color: alpha(var(--surface-variant), 0.65);
413+ color: var(--muted);
414+ border: 0.0625em solid alpha(var(--border), 0.85);
415+ border-radius: 0.1875em;
416+ padding: 0.0625em 0.375em;
417+ font-size: 0.6875em;
418+ font-weight: 500;
419+ transition: background-color 100ms ease-in-out, border-color 100ms ease-in-out, color 100ms ease-in-out;
420+}
421+
422+.omnibox-header-badge:hover {
423+ background-color: shade(var(--surface-variant), 1.08);
424+ border-color: var(--border);
425+ color: var(--text);
426+}
427+
410 /* Zoom indicator in omnibox header */428 /* Zoom indicator in omnibox header */
411 .omnibox-zoom-indicator {429 .omnibox-zoom-indicator {
412 background-color: alpha(var(--accent), 0.2);430 background-color: alpha(var(--accent), 0.2);
M internal/ui/theme/css_test.go +33 -0
@@ -34,6 +34,39 @@ func TestGenerateCSS_OmniboxHeaderIsOpaque(t *testing.T) {
3434 }
3535 }
3636
37+func TestGenerateCSS_OmniboxHeaderBadgeSelectorsExist(t *testing.T) {
38+ css := GenerateCSS(DefaultDarkPalette())
39+
40+ if !strings.Contains(css, ".omnibox-header-badge {") {
41+ t.Fatalf("expected omnibox header badge selector in CSS")
42+ }
43+ if !strings.Contains(css, ".omnibox-header-badge:hover {") {
44+ t.Fatalf("expected omnibox header badge hover selector in CSS")
45+ }
46+}
47+
48+func TestGenerateCSS_OmniboxHeaderBadgeUsesPassiveNeutralChipStyling(t *testing.T) {
49+ css := GenerateCSS(DefaultDarkPalette())
50+
51+ badgeRe := regexp.MustCompile(`(?s)\.omnibox-header-badge\s*\{[^}]*background-color:\s*alpha\(var\(--surface-variant\),\s*0\.65\);[^}]*color:\s*var\(--muted\);[^}]*border:\s*0\.0625em solid alpha\(var\(--border\),\s*0\.85\);[^}]*border-radius:\s*0\.1875em;[^}]*padding:\s*0\.0625em 0\.375em;[^}]*font-size:\s*0\.6875em;`)
52+ if !badgeRe.MatchString(css) {
53+ t.Fatalf("expected omnibox header badge to use passive neutral chip styling")
54+ }
55+
56+ hoverRe := regexp.MustCompile(`(?s)\.omnibox-header-badge:hover\s*\{[^}]*background-color:\s*shade\(var\(--surface-variant\),\s*1\.08\);[^}]*border-color:\s*var\(--border\);[^}]*color:\s*var\(--text\);`)
57+ if !hoverRe.MatchString(css) {
58+ t.Fatalf("expected omnibox header badge hover styling to stay neutral while hinting interactivity")
59+ }
60+
61+ badgeBlock := regexp.MustCompile(`(?s)\.omnibox-header-badge\s*\{[^}]*\}`).FindString(css)
62+ if strings.Contains(badgeBlock, "background-color: alpha(var(--accent)") {
63+ t.Fatalf("expected omnibox header badge resting state to avoid accent-based styling")
64+ }
65+ if strings.Contains(badgeBlock, "color: var(--accent)") {
66+ t.Fatalf("expected omnibox header badge resting text to avoid accent color")
67+ }
68+}
69+
3770 func TestGenerateCSS_FavoriteRowsOnlyTintTrailingAffordance(t *testing.T) {
3871 css := GenerateCSS(DefaultDarkPalette())
3972
@@ -34,6 +34,39 @@ func TestGenerateCSS_OmniboxHeaderIsOpaque(t *testing.T) {
34 }34 }
35 }35 }
36 36
37+func TestGenerateCSS_OmniboxHeaderBadgeSelectorsExist(t *testing.T) {
38+ css := GenerateCSS(DefaultDarkPalette())
39+
40+ if !strings.Contains(css, ".omnibox-header-badge {") {
41+ t.Fatalf("expected omnibox header badge selector in CSS")
42+ }
43+ if !strings.Contains(css, ".omnibox-header-badge:hover {") {
44+ t.Fatalf("expected omnibox header badge hover selector in CSS")
45+ }
46+}
47+
48+func TestGenerateCSS_OmniboxHeaderBadgeUsesPassiveNeutralChipStyling(t *testing.T) {
49+ css := GenerateCSS(DefaultDarkPalette())
50+
51+ badgeRe := regexp.MustCompile(`(?s)\.omnibox-header-badge\s*\{[^}]*background-color:\s*alpha\(var\(--surface-variant\),\s*0\.65\);[^}]*color:\s*var\(--muted\);[^}]*border:\s*0\.0625em solid alpha\(var\(--border\),\s*0\.85\);[^}]*border-radius:\s*0\.1875em;[^}]*padding:\s*0\.0625em 0\.375em;[^}]*font-size:\s*0\.6875em;`)
52+ if !badgeRe.MatchString(css) {
53+ t.Fatalf("expected omnibox header badge to use passive neutral chip styling")
54+ }
55+
56+ hoverRe := regexp.MustCompile(`(?s)\.omnibox-header-badge:hover\s*\{[^}]*background-color:\s*shade\(var\(--surface-variant\),\s*1\.08\);[^}]*border-color:\s*var\(--border\);[^}]*color:\s*var\(--text\);`)
57+ if !hoverRe.MatchString(css) {
58+ t.Fatalf("expected omnibox header badge hover styling to stay neutral while hinting interactivity")
59+ }
60+
61+ badgeBlock := regexp.MustCompile(`(?s)\.omnibox-header-badge\s*\{[^}]*\}`).FindString(css)
62+ if strings.Contains(badgeBlock, "background-color: alpha(var(--accent)") {
63+ t.Fatalf("expected omnibox header badge resting state to avoid accent-based styling")
64+ }
65+ if strings.Contains(badgeBlock, "color: var(--accent)") {
66+ t.Fatalf("expected omnibox header badge resting text to avoid accent color")
67+ }
68+}
69+
37 func TestGenerateCSS_FavoriteRowsOnlyTintTrailingAffordance(t *testing.T) {70 func TestGenerateCSS_FavoriteRowsOnlyTintTrailingAffordance(t *testing.T) {
38 css := GenerateCSS(DefaultDarkPalette())71 css := GenerateCSS(DefaultDarkPalette())
39 72

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close