detached 32b1250

feat(omnibox): persist and toggle default history order

Let the omnibox switch its empty-open history source between recent and most used without leaving the UI. This persists the preference through config, keeps the layers clean, and locks the behavior in with focused tests.
commit
32b1250d6f634762f7d3d75066f7db1f6b269ce4
author
brice · · 2026-04-12 04:10 UTC
parent
48a7b55
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
19 files · +782 -95
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 +50 -0
@@ -0,0 +1,50 @@
1+package config
2+
3+import (
4+ "context"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestOmniboxPreferencesGateway_SaveOmniboxInitialBehavior_UpdatesBehavior(t *testing.T) {
13+ configHome := t.TempDir()
14+ mgr := newTestOmniboxPreferencesGatewayManager(t, configHome)
15+ gateway := NewOmniboxPreferencesGateway(mgr)
16+
17+ require.NoError(t, gateway.SaveOmniboxInitialBehavior(context.Background(), entity.OmniboxInitialBehaviorMostVisited))
18+
19+ reloaded := newTestOmniboxPreferencesGatewayManager(t, configHome)
20+ got := reloaded.Get()
21+ require.Equal(t, entity.OmniboxInitialBehaviorMostVisited, got.Omnibox.InitialBehavior)
22+}
23+
24+func TestOmniboxPreferencesGateway_SaveOmniboxInitialBehavior_PreservesDefaultSearchEngine(t *testing.T) {
25+ configHome := t.TempDir()
26+ mgr := newTestOmniboxPreferencesGatewayManager(t, configHome)
27+ mgr.config.DefaultSearchEngine = "https://example.com/search?q=%s"
28+ gateway := NewOmniboxPreferencesGateway(mgr)
29+
30+ require.NoError(t, gateway.SaveOmniboxInitialBehavior(context.Background(), entity.OmniboxInitialBehaviorMostVisited))
31+
32+ reloaded := newTestOmniboxPreferencesGatewayManager(t, configHome)
33+ got := reloaded.Get()
34+ require.Equal(t, "https://example.com/search?q=%s", got.DefaultSearchEngine)
35+}
36+
37+func newTestOmniboxPreferencesGatewayManager(t *testing.T, configHome string) *Manager {
38+ t.Helper()
39+
40+ t.Setenv("XDG_CONFIG_HOME", configHome)
41+ t.Setenv("XDG_DATA_HOME", filepath.Join(configHome, "data"))
42+ t.Setenv("XDG_STATE_HOME", filepath.Join(configHome, "state"))
43+ t.Setenv("XDG_CACHE_HOME", filepath.Join(configHome, "cache"))
44+
45+ mgr, err := NewManager()
46+ require.NoError(t, err)
47+ require.NoError(t, mgr.Load())
48+
49+ return mgr
50+}
@@ -0,0 +1,50 @@
1+package config
2+
3+import (
4+ "context"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestOmniboxPreferencesGateway_SaveOmniboxInitialBehavior_UpdatesBehavior(t *testing.T) {
13+ configHome := t.TempDir()
14+ mgr := newTestOmniboxPreferencesGatewayManager(t, configHome)
15+ gateway := NewOmniboxPreferencesGateway(mgr)
16+
17+ require.NoError(t, gateway.SaveOmniboxInitialBehavior(context.Background(), entity.OmniboxInitialBehaviorMostVisited))
18+
19+ reloaded := newTestOmniboxPreferencesGatewayManager(t, configHome)
20+ got := reloaded.Get()
21+ require.Equal(t, entity.OmniboxInitialBehaviorMostVisited, got.Omnibox.InitialBehavior)
22+}
23+
24+func TestOmniboxPreferencesGateway_SaveOmniboxInitialBehavior_PreservesDefaultSearchEngine(t *testing.T) {
25+ configHome := t.TempDir()
26+ mgr := newTestOmniboxPreferencesGatewayManager(t, configHome)
27+ mgr.config.DefaultSearchEngine = "https://example.com/search?q=%s"
28+ gateway := NewOmniboxPreferencesGateway(mgr)
29+
30+ require.NoError(t, gateway.SaveOmniboxInitialBehavior(context.Background(), entity.OmniboxInitialBehaviorMostVisited))
31+
32+ reloaded := newTestOmniboxPreferencesGatewayManager(t, configHome)
33+ got := reloaded.Get()
34+ require.Equal(t, "https://example.com/search?q=%s", got.DefaultSearchEngine)
35+}
36+
37+func newTestOmniboxPreferencesGatewayManager(t *testing.T, configHome string) *Manager {
38+ t.Helper()
39+
40+ t.Setenv("XDG_CONFIG_HOME", configHome)
41+ t.Setenv("XDG_DATA_HOME", filepath.Join(configHome, "data"))
42+ t.Setenv("XDG_STATE_HOME", filepath.Join(configHome, "state"))
43+ t.Setenv("XDG_CACHE_HOME", filepath.Join(configHome, "cache"))
44+
45+ mgr, err := NewManager()
46+ require.NoError(t, err)
47+ require.NoError(t, mgr.Load())
48+
49+ return mgr
50+}
M internal/infrastructure/config/schema.go +13 -1
@@ -56,6 +56,18 @@ const (
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/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+ defer func() {
25+ loadInitialHistoryFn = origLoadInitialHistoryFn
26+ }()
27+
28+ if got := o.handleKeyPress(uint(gdk.KEY_r), 0, gdk.ControlMaskValue); !got {
29+ t.Fatalf("handleKeyPress Ctrl+R = false, want true")
30+ }
31+ if got := o.initialBehavior; got != entity.OmniboxInitialBehaviorMostVisited {
32+ t.Fatalf("initialBehavior = %q, want %q", got, entity.OmniboxInitialBehaviorMostVisited)
33+ }
34+ if saveCalls != 1 {
35+ t.Fatalf("saveInitialBehavior calls = %d, want 1", saveCalls)
36+ }
37+ if refreshCalls != 1 {
38+ t.Fatalf("loadInitialHistoryFn calls = %d, want 1", refreshCalls)
39+ }
40+}
41+
42+func TestHandleKeyPress_CtrlRIgnoredWhenBehaviorUnsupported(t *testing.T) {
43+ o := &Omnibox{initialBehavior: entity.OmniboxInitialBehavior("unsupported")}
44+ saveCalls := 0
45+ o.saveInitialBehaviorFn = func(context.Context, entity.OmniboxInitialBehavior) error {
46+ saveCalls++
47+ return nil
48+ }
49+
50+ if got := o.handleKeyPress(uint(gdk.KEY_r), 0, gdk.ControlMaskValue); got {
51+ t.Fatalf("handleKeyPress Ctrl+R = true, want false")
52+ }
53+ if got := o.initialBehavior; got != entity.OmniboxInitialBehavior("unsupported") {
54+ t.Fatalf("initialBehavior = %q, want %q", got, entity.OmniboxInitialBehavior("unsupported"))
55+ }
56+ if saveCalls != 0 {
57+ t.Fatalf("saveInitialBehavior calls = %d, want 0", saveCalls)
58+ }
59+}
@@ -0,0 +1,59 @@
1+package component
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/puregotk/v4/gdk"
9+)
10+
11+func TestHandleKeyPress_CtrlRTriggersInitialBehaviorToggle(t *testing.T) {
12+ o := &Omnibox{initialBehavior: entity.OmniboxInitialBehaviorRecent, viewMode: ViewModeHistory}
13+ saveCalls := 0
14+ o.saveInitialBehaviorFn = func(context.Context, entity.OmniboxInitialBehavior) error {
15+ saveCalls++
16+ return nil
17+ }
18+
19+ var refreshCalls int
20+ origLoadInitialHistoryFn := loadInitialHistoryFn
21+ loadInitialHistoryFn = func(*Omnibox, uint64) {
22+ refreshCalls++
23+ }
24+ defer func() {
25+ loadInitialHistoryFn = origLoadInitialHistoryFn
26+ }()
27+
28+ if got := o.handleKeyPress(uint(gdk.KEY_r), 0, gdk.ControlMaskValue); !got {
29+ t.Fatalf("handleKeyPress Ctrl+R = false, want true")
30+ }
31+ if got := o.initialBehavior; got != entity.OmniboxInitialBehaviorMostVisited {
32+ t.Fatalf("initialBehavior = %q, want %q", got, entity.OmniboxInitialBehaviorMostVisited)
33+ }
34+ if saveCalls != 1 {
35+ t.Fatalf("saveInitialBehavior calls = %d, want 1", saveCalls)
36+ }
37+ if refreshCalls != 1 {
38+ t.Fatalf("loadInitialHistoryFn calls = %d, want 1", refreshCalls)
39+ }
40+}
41+
42+func TestHandleKeyPress_CtrlRIgnoredWhenBehaviorUnsupported(t *testing.T) {
43+ o := &Omnibox{initialBehavior: entity.OmniboxInitialBehavior("unsupported")}
44+ saveCalls := 0
45+ o.saveInitialBehaviorFn = func(context.Context, entity.OmniboxInitialBehavior) error {
46+ saveCalls++
47+ return nil
48+ }
49+
50+ if got := o.handleKeyPress(uint(gdk.KEY_r), 0, gdk.ControlMaskValue); got {
51+ t.Fatalf("handleKeyPress Ctrl+R = true, want false")
52+ }
53+ if got := o.initialBehavior; got != entity.OmniboxInitialBehavior("unsupported") {
54+ t.Fatalf("initialBehavior = %q, want %q", got, entity.OmniboxInitialBehavior("unsupported"))
55+ }
56+ if saveCalls != 0 {
57+ t.Fatalf("saveInitialBehavior calls = %d, want 0", saveCalls)
58+ }
59+}
M internal/ui/deps.go +3 -0
@@ -120,6 +120,9 @@ func (d *Dependencies) Validate() error {
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 }

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