detached 5d04e80

feat(systemviews): add config route

commit
5d04e809032eceb2c131a2dbdff8e1471fddd2a2
author
brice · · 2026-04-11 19:04 UTC
committer
brice · · 2026-04-12 04:52 UTC
parent
681c467
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +399 -0
M internal/infrastructure/systemviewsbridge/client.go +41 -0
@@ -23,6 +23,8 @@ type Client struct {
2323 fetch Transport
2424 }
2525
26+var _ port.SystemviewConfigService = (*Client)(nil)
27+
2628 var requestSeq atomic.Uint64
2729
2830 // NewClient creates a bridge client with native WebKit and fetch transports.
@@ -81,6 +83,45 @@ func (c *Client) DeleteRange(ctx context.Context, rangeID string) error {
8183 return err
8284 }
8385
86+func (c *Client) Current(ctx context.Context) (port.SystemviewConfigPayload, error) {
87+ return request[port.SystemviewConfigPayload](c, ctx, "/api/config", struct {
88+ RequestID string `json:"requestId"`
89+ }{RequestID: nextRequestID()})
90+}
91+
92+func (c *Client) Default(ctx context.Context) (port.SystemviewConfigPayload, error) {
93+ return request[port.SystemviewConfigPayload](c, ctx, "/api/config/default", struct {
94+ RequestID string `json:"requestId"`
95+ }{RequestID: nextRequestID()})
96+}
97+
98+func (c *Client) Save(ctx context.Context, cfg port.WebUIConfig) error {
99+ _, err := request[struct{}](c, ctx, "save_config", cfg)
100+ return err
101+}
102+
103+func (c *Client) GetKeybindings(ctx context.Context) (any, error) {
104+ return request[any](c, ctx, "get_keybindings", struct {
105+ RequestID string `json:"requestId"`
106+ }{RequestID: nextRequestID()})
107+}
108+
109+func (c *Client) SetKeybinding(ctx context.Context, req port.SetKeybindingRequest) (any, error) {
110+ return request[any](c, ctx, "set_keybinding", req)
111+}
112+
113+func (c *Client) ResetKeybinding(ctx context.Context, req port.ResetKeybindingRequest) error {
114+ _, err := request[struct{}](c, ctx, "reset_keybinding", req)
115+ return err
116+}
117+
118+func (c *Client) ResetAllKeybindings(ctx context.Context) error {
119+ _, err := request[struct{}](c, ctx, "reset_all_keybindings", struct {
120+ RequestID string `json:"requestId"`
121+ }{RequestID: nextRequestID()})
122+ return err
123+}
124+
84125 func (c *Client) Analytics(ctx context.Context) (*entity.HistoryAnalytics, error) {
85126 return request[*entity.HistoryAnalytics](c, ctx, "history_analytics", struct {
86127 RequestID string `json:"requestId"`
@@ -23,6 +23,8 @@ type Client struct {
23 fetch Transport23 fetch Transport
24 }24 }
25 25
26+var _ port.SystemviewConfigService = (*Client)(nil)
27+
26 var requestSeq atomic.Uint6428 var requestSeq atomic.Uint64
27 29
28 // NewClient creates a bridge client with native WebKit and fetch transports.30 // NewClient creates a bridge client with native WebKit and fetch transports.
@@ -81,6 +83,45 @@ func (c *Client) DeleteRange(ctx context.Context, rangeID string) error {
81 return err83 return err
82 }84 }
83 85
86+func (c *Client) Current(ctx context.Context) (port.SystemviewConfigPayload, error) {
87+ return request[port.SystemviewConfigPayload](c, ctx, "/api/config", struct {
88+ RequestID string `json:"requestId"`
89+ }{RequestID: nextRequestID()})
90+}
91+
92+func (c *Client) Default(ctx context.Context) (port.SystemviewConfigPayload, error) {
93+ return request[port.SystemviewConfigPayload](c, ctx, "/api/config/default", struct {
94+ RequestID string `json:"requestId"`
95+ }{RequestID: nextRequestID()})
96+}
97+
98+func (c *Client) Save(ctx context.Context, cfg port.WebUIConfig) error {
99+ _, err := request[struct{}](c, ctx, "save_config", cfg)
100+ return err
101+}
102+
103+func (c *Client) GetKeybindings(ctx context.Context) (any, error) {
104+ return request[any](c, ctx, "get_keybindings", struct {
105+ RequestID string `json:"requestId"`
106+ }{RequestID: nextRequestID()})
107+}
108+
109+func (c *Client) SetKeybinding(ctx context.Context, req port.SetKeybindingRequest) (any, error) {
110+ return request[any](c, ctx, "set_keybinding", req)
111+}
112+
113+func (c *Client) ResetKeybinding(ctx context.Context, req port.ResetKeybindingRequest) error {
114+ _, err := request[struct{}](c, ctx, "reset_keybinding", req)
115+ return err
116+}
117+
118+func (c *Client) ResetAllKeybindings(ctx context.Context) error {
119+ _, err := request[struct{}](c, ctx, "reset_all_keybindings", struct {
120+ RequestID string `json:"requestId"`
121+ }{RequestID: nextRequestID()})
122+ return err
123+}
124+
84 func (c *Client) Analytics(ctx context.Context) (*entity.HistoryAnalytics, error) {125 func (c *Client) Analytics(ctx context.Context) (*entity.HistoryAnalytics, error) {
85 return request[*entity.HistoryAnalytics](c, ctx, "history_analytics", struct {126 return request[*entity.HistoryAnalytics](c, ctx, "history_analytics", struct {
86 RequestID string `json:"requestId"`127 RequestID string `json:"requestId"`
M internal/infrastructure/systemviewsbridge/client_test.go +135 -0
@@ -221,6 +221,141 @@ func TestClientListTagsDecodesTags(t *testing.T) {
221221 }
222222 }
223223
224+func TestClientCurrentAndDefaultDecodeConfigPayload(t *testing.T) {
225+ t.Parallel()
226+
227+ resp := []byte(`{"requestId":"req-14","success":true,"data":{"engine_type":"webkit","default_search_engine":"DuckDuckGo"}}`)
228+
229+ currentClient := NewClient(&fakeTransport{available: true, response: resp}, nil)
230+ current, err := currentClient.Current(context.Background())
231+ if err != nil {
232+ t.Fatalf("Current() error = %v", err)
233+ }
234+ if current.EngineType != "webkit" || current.DefaultSearchEngine != "DuckDuckGo" {
235+ t.Fatalf("Current() = %+v", current)
236+ }
237+
238+ var msg port.WebUIMessage
239+ if err := json.Unmarshal(currentClient.native.(*fakeTransport).last, &msg); err != nil {
240+ t.Fatalf("unmarshal sent envelope: %v", err)
241+ }
242+ if msg.Type != "/api/config" {
243+ t.Fatalf("sent type = %q, want %q", msg.Type, "/api/config")
244+ }
245+
246+ defaultClient := NewClient(&fakeTransport{available: true, response: resp}, nil)
247+ defaultCfg, err := defaultClient.Default(context.Background())
248+ if err != nil {
249+ t.Fatalf("Default() error = %v", err)
250+ }
251+ if defaultCfg.EngineType != "webkit" || defaultCfg.DefaultSearchEngine != "DuckDuckGo" {
252+ t.Fatalf("Default() = %+v", defaultCfg)
253+ }
254+
255+ if err := json.Unmarshal(defaultClient.native.(*fakeTransport).last, &msg); err != nil {
256+ t.Fatalf("unmarshal sent envelope: %v", err)
257+ }
258+ if msg.Type != "/api/config/default" {
259+ t.Fatalf("sent type = %q, want %q", msg.Type, "/api/config/default")
260+ }
261+}
262+
263+func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) {
264+ t.Parallel()
265+
266+ tests := []struct {
267+ name string
268+ call func(*Client) error
269+ want string
270+ payload any
271+ }{
272+ {
273+ name: "save config",
274+ call: func(client *Client) error {
275+ return client.Save(context.Background(), port.WebUIConfig{DefaultSearchEngine: "DuckDuckGo"})
276+ },
277+ want: "save_config",
278+ payload: port.WebUIConfig{DefaultSearchEngine: "DuckDuckGo"},
279+ },
280+ {
281+ name: "get keybindings",
282+ call: func(client *Client) error {
283+ got, err := client.GetKeybindings(context.Background())
284+ if err != nil {
285+ return err
286+ }
287+ m, ok := got.(map[string]any)
288+ if !ok || len(m) == 0 {
289+ t.Fatalf("GetKeybindings() = %#v", got)
290+ }
291+ return nil
292+ },
293+ want: "get_keybindings",
294+ },
295+ {
296+ name: "set keybinding",
297+ call: func(client *Client) error {
298+ got, err := client.SetKeybinding(context.Background(), port.SetKeybindingRequest{RequestID: "req-1", Mode: "default", Action: "open", Keys: []string{"ctrl+o"}})
299+ if err != nil {
300+ return err
301+ }
302+ m, ok := got.(map[string]any)
303+ if !ok || m["status"] != "success" {
304+ t.Fatalf("SetKeybinding() = %#v", got)
305+ }
306+ return nil
307+ },
308+ want: "set_keybinding",
309+ payload: port.SetKeybindingRequest{RequestID: "req-1", Mode: "default", Action: "open", Keys: []string{"ctrl+o"}},
310+ },
311+ {
312+ name: "reset keybinding",
313+ call: func(client *Client) error {
314+ return client.ResetKeybinding(context.Background(), port.ResetKeybindingRequest{RequestID: "req-2", Mode: "default", Action: "open"})
315+ },
316+ want: "reset_keybinding",
317+ payload: port.ResetKeybindingRequest{RequestID: "req-2", Mode: "default", Action: "open"},
318+ },
319+ {
320+ name: "reset all keybindings",
321+ call: func(client *Client) error {
322+ return client.ResetAllKeybindings(context.Background())
323+ },
324+ want: "reset_all_keybindings",
325+ },
326+ }
327+
328+ for _, tt := range tests {
329+ t.Run(tt.name, func(t *testing.T) {
330+ t.Parallel()
331+
332+ transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-15","success":true,"data":{"status":"success","conflicts":[]}}`)}
333+ if tt.name == "save config" || tt.name == "reset keybinding" || tt.name == "reset all keybindings" {
334+ transport.response = []byte(`{"requestId":"req-15","success":true}`)
335+ }
336+ if tt.name == "get keybindings" {
337+ transport.response = []byte(`{"requestId":"req-15","success":true,"data":{"groups":[{"mode":"default","display_name":"Default","bindings":[{"action":"open","description":"Open","keys":["ctrl+o"],"default_keys":["ctrl+o"],"is_custom":false}]}]}}`)
338+ }
339+ client := NewClient(transport, nil)
340+
341+ if err := tt.call(client); err != nil {
342+ t.Fatalf("call() error = %v", err)
343+ }
344+
345+ var msg port.WebUIMessage
346+ if err := json.Unmarshal(transport.last, &msg); err != nil {
347+ t.Fatalf("unmarshal sent envelope: %v", err)
348+ }
349+ if msg.Type != tt.want {
350+ t.Fatalf("sent type = %q, want %q", msg.Type, tt.want)
351+ }
352+ if tt.payload != nil && len(msg.Payload) == 0 {
353+ t.Fatal("sent payload was empty")
354+ }
355+ })
356+ }
357+}
358+
224359 type fakeTransport struct {
225360 available bool
226361 called bool
@@ -221,6 +221,141 @@ func TestClientListTagsDecodesTags(t *testing.T) {
221 }221 }
222 }222 }
223 223
224+func TestClientCurrentAndDefaultDecodeConfigPayload(t *testing.T) {
225+ t.Parallel()
226+
227+ resp := []byte(`{"requestId":"req-14","success":true,"data":{"engine_type":"webkit","default_search_engine":"DuckDuckGo"}}`)
228+
229+ currentClient := NewClient(&fakeTransport{available: true, response: resp}, nil)
230+ current, err := currentClient.Current(context.Background())
231+ if err != nil {
232+ t.Fatalf("Current() error = %v", err)
233+ }
234+ if current.EngineType != "webkit" || current.DefaultSearchEngine != "DuckDuckGo" {
235+ t.Fatalf("Current() = %+v", current)
236+ }
237+
238+ var msg port.WebUIMessage
239+ if err := json.Unmarshal(currentClient.native.(*fakeTransport).last, &msg); err != nil {
240+ t.Fatalf("unmarshal sent envelope: %v", err)
241+ }
242+ if msg.Type != "/api/config" {
243+ t.Fatalf("sent type = %q, want %q", msg.Type, "/api/config")
244+ }
245+
246+ defaultClient := NewClient(&fakeTransport{available: true, response: resp}, nil)
247+ defaultCfg, err := defaultClient.Default(context.Background())
248+ if err != nil {
249+ t.Fatalf("Default() error = %v", err)
250+ }
251+ if defaultCfg.EngineType != "webkit" || defaultCfg.DefaultSearchEngine != "DuckDuckGo" {
252+ t.Fatalf("Default() = %+v", defaultCfg)
253+ }
254+
255+ if err := json.Unmarshal(defaultClient.native.(*fakeTransport).last, &msg); err != nil {
256+ t.Fatalf("unmarshal sent envelope: %v", err)
257+ }
258+ if msg.Type != "/api/config/default" {
259+ t.Fatalf("sent type = %q, want %q", msg.Type, "/api/config/default")
260+ }
261+}
262+
263+func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) {
264+ t.Parallel()
265+
266+ tests := []struct {
267+ name string
268+ call func(*Client) error
269+ want string
270+ payload any
271+ }{
272+ {
273+ name: "save config",
274+ call: func(client *Client) error {
275+ return client.Save(context.Background(), port.WebUIConfig{DefaultSearchEngine: "DuckDuckGo"})
276+ },
277+ want: "save_config",
278+ payload: port.WebUIConfig{DefaultSearchEngine: "DuckDuckGo"},
279+ },
280+ {
281+ name: "get keybindings",
282+ call: func(client *Client) error {
283+ got, err := client.GetKeybindings(context.Background())
284+ if err != nil {
285+ return err
286+ }
287+ m, ok := got.(map[string]any)
288+ if !ok || len(m) == 0 {
289+ t.Fatalf("GetKeybindings() = %#v", got)
290+ }
291+ return nil
292+ },
293+ want: "get_keybindings",
294+ },
295+ {
296+ name: "set keybinding",
297+ call: func(client *Client) error {
298+ got, err := client.SetKeybinding(context.Background(), port.SetKeybindingRequest{RequestID: "req-1", Mode: "default", Action: "open", Keys: []string{"ctrl+o"}})
299+ if err != nil {
300+ return err
301+ }
302+ m, ok := got.(map[string]any)
303+ if !ok || m["status"] != "success" {
304+ t.Fatalf("SetKeybinding() = %#v", got)
305+ }
306+ return nil
307+ },
308+ want: "set_keybinding",
309+ payload: port.SetKeybindingRequest{RequestID: "req-1", Mode: "default", Action: "open", Keys: []string{"ctrl+o"}},
310+ },
311+ {
312+ name: "reset keybinding",
313+ call: func(client *Client) error {
314+ return client.ResetKeybinding(context.Background(), port.ResetKeybindingRequest{RequestID: "req-2", Mode: "default", Action: "open"})
315+ },
316+ want: "reset_keybinding",
317+ payload: port.ResetKeybindingRequest{RequestID: "req-2", Mode: "default", Action: "open"},
318+ },
319+ {
320+ name: "reset all keybindings",
321+ call: func(client *Client) error {
322+ return client.ResetAllKeybindings(context.Background())
323+ },
324+ want: "reset_all_keybindings",
325+ },
326+ }
327+
328+ for _, tt := range tests {
329+ t.Run(tt.name, func(t *testing.T) {
330+ t.Parallel()
331+
332+ transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-15","success":true,"data":{"status":"success","conflicts":[]}}`)}
333+ if tt.name == "save config" || tt.name == "reset keybinding" || tt.name == "reset all keybindings" {
334+ transport.response = []byte(`{"requestId":"req-15","success":true}`)
335+ }
336+ if tt.name == "get keybindings" {
337+ transport.response = []byte(`{"requestId":"req-15","success":true,"data":{"groups":[{"mode":"default","display_name":"Default","bindings":[{"action":"open","description":"Open","keys":["ctrl+o"],"default_keys":["ctrl+o"],"is_custom":false}]}]}}`)
338+ }
339+ client := NewClient(transport, nil)
340+
341+ if err := tt.call(client); err != nil {
342+ t.Fatalf("call() error = %v", err)
343+ }
344+
345+ var msg port.WebUIMessage
346+ if err := json.Unmarshal(transport.last, &msg); err != nil {
347+ t.Fatalf("unmarshal sent envelope: %v", err)
348+ }
349+ if msg.Type != tt.want {
350+ t.Fatalf("sent type = %q, want %q", msg.Type, tt.want)
351+ }
352+ if tt.payload != nil && len(msg.Payload) == 0 {
353+ t.Fatal("sent payload was empty")
354+ }
355+ })
356+ }
357+}
358+
224 type fakeTransport struct {359 type fakeTransport struct {
225 available bool360 available bool
226 called bool361 called bool
M internal/ui/systemviews/app.go +34 -0
@@ -12,6 +12,7 @@ type Dependencies struct {
1212 DOM DOM
1313 Favorites port.SystemviewFavoritesService
1414 History port.SystemviewHistoryService
15+ Config port.SystemviewConfigService
1516 LocationURI string
1617 }
1718
@@ -22,6 +23,8 @@ type App struct {
2223 favorites []*entity.Favorite
2324 folders []*entity.Folder
2425 tags []*entity.Tag
26+ config *port.SystemviewConfigPayload
27+ keybindings any
2528 renderedHTML string
2629 }
2730
@@ -103,11 +106,42 @@ func (a *App) LoadInitial(ctx context.Context) error {
103106 a.tags = tags
104107 a.renderedHTML = favoritesHTML(favorites, folders, tags)
105108 return nil
109+ case RouteConfig:
110+ if a.deps.Config == nil {
111+ a.historyEntries = nil
112+ a.favorites = nil
113+ a.folders = nil
114+ a.tags = nil
115+ a.config = nil
116+ a.keybindings = nil
117+ a.renderedHTML = placeholderHTML(a.currentRoute)
118+ return nil
119+ }
120+
121+ config, err := a.deps.Config.Current(ctx)
122+ if err != nil {
123+ return err
124+ }
125+ keybindings, err := a.deps.Config.GetKeybindings(ctx)
126+ if err != nil {
127+ return err
128+ }
129+
130+ a.historyEntries = nil
131+ a.favorites = nil
132+ a.folders = nil
133+ a.tags = nil
134+ a.config = &config
135+ a.keybindings = keybindings
136+ a.renderedHTML = configHTML(config, keybindings)
137+ return nil
106138 default:
107139 a.historyEntries = nil
108140 a.favorites = nil
109141 a.folders = nil
110142 a.tags = nil
143+ a.config = nil
144+ a.keybindings = nil
111145 a.renderedHTML = placeholderHTML(a.currentRoute)
112146 return nil
113147 }
@@ -12,6 +12,7 @@ type Dependencies struct {
12 DOM DOM12 DOM DOM
13 Favorites port.SystemviewFavoritesService13 Favorites port.SystemviewFavoritesService
14 History port.SystemviewHistoryService14 History port.SystemviewHistoryService
15+ Config port.SystemviewConfigService
15 LocationURI string16 LocationURI string
16 }17 }
17 18
@@ -22,6 +23,8 @@ type App struct {
22 favorites []*entity.Favorite23 favorites []*entity.Favorite
23 folders []*entity.Folder24 folders []*entity.Folder
24 tags []*entity.Tag25 tags []*entity.Tag
26+ config *port.SystemviewConfigPayload
27+ keybindings any
25 renderedHTML string28 renderedHTML string
26 }29 }
27 30
@@ -103,11 +106,42 @@ func (a *App) LoadInitial(ctx context.Context) error {
103 a.tags = tags106 a.tags = tags
104 a.renderedHTML = favoritesHTML(favorites, folders, tags)107 a.renderedHTML = favoritesHTML(favorites, folders, tags)
105 return nil108 return nil
109+ case RouteConfig:
110+ if a.deps.Config == nil {
111+ a.historyEntries = nil
112+ a.favorites = nil
113+ a.folders = nil
114+ a.tags = nil
115+ a.config = nil
116+ a.keybindings = nil
117+ a.renderedHTML = placeholderHTML(a.currentRoute)
118+ return nil
119+ }
120+
121+ config, err := a.deps.Config.Current(ctx)
122+ if err != nil {
123+ return err
124+ }
125+ keybindings, err := a.deps.Config.GetKeybindings(ctx)
126+ if err != nil {
127+ return err
128+ }
129+
130+ a.historyEntries = nil
131+ a.favorites = nil
132+ a.folders = nil
133+ a.tags = nil
134+ a.config = &config
135+ a.keybindings = keybindings
136+ a.renderedHTML = configHTML(config, keybindings)
137+ return nil
106 default:138 default:
107 a.historyEntries = nil139 a.historyEntries = nil
108 a.favorites = nil140 a.favorites = nil
109 a.folders = nil141 a.folders = nil
110 a.tags = nil142 a.tags = nil
143+ a.config = nil
144+ a.keybindings = nil
111 a.renderedHTML = placeholderHTML(a.currentRoute)145 a.renderedHTML = placeholderHTML(a.currentRoute)
112 return nil146 return nil
113 }147 }
M internal/ui/systemviews/app_test.go +78 -0
@@ -4,6 +4,7 @@ import (
44 "context"
55 "testing"
66
7+ "github.com/bnema/dumber/internal/application/port"
78 "github.com/bnema/dumber/internal/domain/entity"
89 "github.com/stretchr/testify/assert"
910 "github.com/stretchr/testify/require"
@@ -102,6 +103,34 @@ func TestAppLoadInitialFavoritesRouteRendersData(t *testing.T) {
102103 assert.True(t, favorites.calledTags)
103104 }
104105
106+func TestAppLoadInitialConfigRouteRendersData(t *testing.T) {
107+ t.Parallel()
108+
109+ config := port.SystemviewConfigPayload{
110+ EngineType: "webkit",
111+ DefaultSearchEngine: "DuckDuckGo",
112+ }
113+ service := &fakeConfigService{
114+ current: config,
115+ keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{{Mode: "default", DisplayName: "Default", Bindings: []port.KeybindingEntry{{Action: "open", Description: "Open", Keys: []string{"ctrl+o"}}}}}},
116+ }
117+
118+ app := NewApp(Dependencies{
119+ Config: service,
120+ LocationURI: "dumb://config",
121+ })
122+
123+ require.NoError(t, app.LoadInitial(context.Background()))
124+ assert.Equal(t, RouteConfig, app.CurrentRoute())
125+ require.NotNil(t, app.config)
126+ assert.Equal(t, "webkit", app.config.EngineType)
127+ assert.Contains(t, app.renderedHTML, "webkit")
128+ assert.Contains(t, app.renderedHTML, "DuckDuckGo")
129+ assert.Contains(t, app.renderedHTML, "1 group")
130+ assert.True(t, service.calledCurrent)
131+ assert.True(t, service.calledKeybindings)
132+}
133+
105134 type fakeDOM struct {
106135 mounted bool
107136 html string
@@ -192,3 +221,52 @@ func (s *fakeFavoritesService) DeleteTag(context.Context, int64) error { return
192221 func (s *fakeFavoritesService) AssignTag(context.Context, int64, int64) error { return nil }
193222
194223 func (s *fakeFavoritesService) RemoveTag(context.Context, int64, int64) error { return nil }
224+
225+type fakeConfigService struct {
226+ calledCurrent bool
227+ calledDefault bool
228+ calledSave bool
229+ calledKeybindings bool
230+ calledSet bool
231+ calledReset bool
232+ calledResetAll bool
233+
234+ current port.SystemviewConfigPayload
235+ defaultCfg port.SystemviewConfigPayload
236+ keybindings any
237+}
238+
239+func (s *fakeConfigService) Current(context.Context) (port.SystemviewConfigPayload, error) {
240+ s.calledCurrent = true
241+ return s.current, nil
242+}
243+
244+func (s *fakeConfigService) Default(context.Context) (port.SystemviewConfigPayload, error) {
245+ s.calledDefault = true
246+ return s.defaultCfg, nil
247+}
248+
249+func (s *fakeConfigService) Save(context.Context, port.WebUIConfig) error {
250+ s.calledSave = true
251+ return nil
252+}
253+
254+func (s *fakeConfigService) GetKeybindings(context.Context) (any, error) {
255+ s.calledKeybindings = true
256+ return s.keybindings, nil
257+}
258+
259+func (s *fakeConfigService) SetKeybinding(context.Context, port.SetKeybindingRequest) (any, error) {
260+ s.calledSet = true
261+ return nil, nil
262+}
263+
264+func (s *fakeConfigService) ResetKeybinding(context.Context, port.ResetKeybindingRequest) error {
265+ s.calledReset = true
266+ return nil
267+}
268+
269+func (s *fakeConfigService) ResetAllKeybindings(context.Context) error {
270+ s.calledResetAll = true
271+ return nil
272+}
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "testing"5 "testing"
6 6
7+ "github.com/bnema/dumber/internal/application/port"
7 "github.com/bnema/dumber/internal/domain/entity"8 "github.com/bnema/dumber/internal/domain/entity"
8 "github.com/stretchr/testify/assert"9 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
@@ -102,6 +103,34 @@ func TestAppLoadInitialFavoritesRouteRendersData(t *testing.T) {
102 assert.True(t, favorites.calledTags)103 assert.True(t, favorites.calledTags)
103 }104 }
104 105
106+func TestAppLoadInitialConfigRouteRendersData(t *testing.T) {
107+ t.Parallel()
108+
109+ config := port.SystemviewConfigPayload{
110+ EngineType: "webkit",
111+ DefaultSearchEngine: "DuckDuckGo",
112+ }
113+ service := &fakeConfigService{
114+ current: config,
115+ keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{{Mode: "default", DisplayName: "Default", Bindings: []port.KeybindingEntry{{Action: "open", Description: "Open", Keys: []string{"ctrl+o"}}}}}},
116+ }
117+
118+ app := NewApp(Dependencies{
119+ Config: service,
120+ LocationURI: "dumb://config",
121+ })
122+
123+ require.NoError(t, app.LoadInitial(context.Background()))
124+ assert.Equal(t, RouteConfig, app.CurrentRoute())
125+ require.NotNil(t, app.config)
126+ assert.Equal(t, "webkit", app.config.EngineType)
127+ assert.Contains(t, app.renderedHTML, "webkit")
128+ assert.Contains(t, app.renderedHTML, "DuckDuckGo")
129+ assert.Contains(t, app.renderedHTML, "1 group")
130+ assert.True(t, service.calledCurrent)
131+ assert.True(t, service.calledKeybindings)
132+}
133+
105 type fakeDOM struct {134 type fakeDOM struct {
106 mounted bool135 mounted bool
107 html string136 html string
@@ -192,3 +221,52 @@ func (s *fakeFavoritesService) DeleteTag(context.Context, int64) error { return
192 func (s *fakeFavoritesService) AssignTag(context.Context, int64, int64) error { return nil }221 func (s *fakeFavoritesService) AssignTag(context.Context, int64, int64) error { return nil }
193 222
194 func (s *fakeFavoritesService) RemoveTag(context.Context, int64, int64) error { return nil }223 func (s *fakeFavoritesService) RemoveTag(context.Context, int64, int64) error { return nil }
224+
225+type fakeConfigService struct {
226+ calledCurrent bool
227+ calledDefault bool
228+ calledSave bool
229+ calledKeybindings bool
230+ calledSet bool
231+ calledReset bool
232+ calledResetAll bool
233+
234+ current port.SystemviewConfigPayload
235+ defaultCfg port.SystemviewConfigPayload
236+ keybindings any
237+}
238+
239+func (s *fakeConfigService) Current(context.Context) (port.SystemviewConfigPayload, error) {
240+ s.calledCurrent = true
241+ return s.current, nil
242+}
243+
244+func (s *fakeConfigService) Default(context.Context) (port.SystemviewConfigPayload, error) {
245+ s.calledDefault = true
246+ return s.defaultCfg, nil
247+}
248+
249+func (s *fakeConfigService) Save(context.Context, port.WebUIConfig) error {
250+ s.calledSave = true
251+ return nil
252+}
253+
254+func (s *fakeConfigService) GetKeybindings(context.Context) (any, error) {
255+ s.calledKeybindings = true
256+ return s.keybindings, nil
257+}
258+
259+func (s *fakeConfigService) SetKeybinding(context.Context, port.SetKeybindingRequest) (any, error) {
260+ s.calledSet = true
261+ return nil, nil
262+}
263+
264+func (s *fakeConfigService) ResetKeybinding(context.Context, port.ResetKeybindingRequest) error {
265+ s.calledReset = true
266+ return nil
267+}
268+
269+func (s *fakeConfigService) ResetAllKeybindings(context.Context) error {
270+ s.calledResetAll = true
271+ return nil
272+}
A internal/ui/systemviews/config_view.go +111 -0
@@ -0,0 +1,111 @@
1+package systemviews
2+
3+import (
4+ "fmt"
5+ "html"
6+ "strings"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+)
10+
11+func configHTML(cfg port.SystemviewConfigPayload, keybindings any) string {
12+ groups, bindings, loaded := summarizeKeybindings(keybindings)
13+ status := "not loaded"
14+ if loaded {
15+ status = "loaded"
16+ }
17+
18+ return fmt.Sprintf(`<!doctype html>
19+<html lang="en">
20+<head>
21+ <meta charset="utf-8">
22+ <meta name="viewport" content="width=device-width, initial-scale=1">
23+ <title>systemviews config</title>
24+</head>
25+<body>
26+ <main id="app" data-route="config">
27+ <h1>Config</h1>
28+ <p>Engine: %s</p>
29+ <p>Default search engine: %s</p>
30+ <p>Keybindings: %s (%d %s, %d %s)</p>
31+ </main>
32+</body>
33+</html>`,
34+ html.EscapeString(cfg.EngineType),
35+ html.EscapeString(cfg.DefaultSearchEngine),
36+ status,
37+ groups, plural(groups, "group"),
38+ bindings, plural(bindings, "binding"),
39+ )
40+}
41+
42+func summarizeKeybindings(keybindings any) (groups int, bindings int, loaded bool) {
43+ switch v := keybindings.(type) {
44+ case nil:
45+ return 0, 0, false
46+ case port.KeybindingsConfig:
47+ return countKeybindingGroups(v.Groups), countKeybindingBindings(v.Groups), true
48+ case *port.KeybindingsConfig:
49+ if v == nil {
50+ return 0, 0, false
51+ }
52+ return countKeybindingGroups(v.Groups), countKeybindingBindings(v.Groups), true
53+ case map[string]any:
54+ return countBridgeKeybindings(v)
55+ default:
56+ return 1, 0, true
57+ }
58+}
59+
60+func countBridgeKeybindings(data map[string]any) (groups int, bindings int, loaded bool) {
61+ groupValue, ok := data["groups"]
62+ if !ok {
63+ return 1, 0, true
64+ }
65+
66+ groupList, ok := groupValue.([]any)
67+ if !ok {
68+ return 1, 0, true
69+ }
70+
71+ for _, group := range groupList {
72+ groupMap, ok := group.(map[string]any)
73+ if !ok {
74+ continue
75+ }
76+ groups++
77+ if bindingValue, ok := groupMap["bindings"]; ok {
78+ if bindingList, ok := bindingValue.([]any); ok {
79+ bindings += len(bindingList)
80+ }
81+ }
82+ }
83+
84+ return groups, bindings, true
85+}
86+
87+func countKeybindingGroups(groups []port.KeybindingGroup) int {
88+ count := 0
89+ for _, group := range groups {
90+ if group.Mode == "" && group.DisplayName == "" && len(group.Bindings) == 0 {
91+ continue
92+ }
93+ count++
94+ }
95+ return count
96+}
97+
98+func countKeybindingBindings(groups []port.KeybindingGroup) int {
99+ count := 0
100+ for _, group := range groups {
101+ count += len(group.Bindings)
102+ }
103+ return count
104+}
105+
106+func plural(count int, singular string) string {
107+ if count == 1 {
108+ return singular
109+ }
110+ return strings.TrimSpace(singular + "s")
111+}
@@ -0,0 +1,111 @@
1+package systemviews
2+
3+import (
4+ "fmt"
5+ "html"
6+ "strings"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+)
10+
11+func configHTML(cfg port.SystemviewConfigPayload, keybindings any) string {
12+ groups, bindings, loaded := summarizeKeybindings(keybindings)
13+ status := "not loaded"
14+ if loaded {
15+ status = "loaded"
16+ }
17+
18+ return fmt.Sprintf(`<!doctype html>
19+<html lang="en">
20+<head>
21+ <meta charset="utf-8">
22+ <meta name="viewport" content="width=device-width, initial-scale=1">
23+ <title>systemviews config</title>
24+</head>
25+<body>
26+ <main id="app" data-route="config">
27+ <h1>Config</h1>
28+ <p>Engine: %s</p>
29+ <p>Default search engine: %s</p>
30+ <p>Keybindings: %s (%d %s, %d %s)</p>
31+ </main>
32+</body>
33+</html>`,
34+ html.EscapeString(cfg.EngineType),
35+ html.EscapeString(cfg.DefaultSearchEngine),
36+ status,
37+ groups, plural(groups, "group"),
38+ bindings, plural(bindings, "binding"),
39+ )
40+}
41+
42+func summarizeKeybindings(keybindings any) (groups int, bindings int, loaded bool) {
43+ switch v := keybindings.(type) {
44+ case nil:
45+ return 0, 0, false
46+ case port.KeybindingsConfig:
47+ return countKeybindingGroups(v.Groups), countKeybindingBindings(v.Groups), true
48+ case *port.KeybindingsConfig:
49+ if v == nil {
50+ return 0, 0, false
51+ }
52+ return countKeybindingGroups(v.Groups), countKeybindingBindings(v.Groups), true
53+ case map[string]any:
54+ return countBridgeKeybindings(v)
55+ default:
56+ return 1, 0, true
57+ }
58+}
59+
60+func countBridgeKeybindings(data map[string]any) (groups int, bindings int, loaded bool) {
61+ groupValue, ok := data["groups"]
62+ if !ok {
63+ return 1, 0, true
64+ }
65+
66+ groupList, ok := groupValue.([]any)
67+ if !ok {
68+ return 1, 0, true
69+ }
70+
71+ for _, group := range groupList {
72+ groupMap, ok := group.(map[string]any)
73+ if !ok {
74+ continue
75+ }
76+ groups++
77+ if bindingValue, ok := groupMap["bindings"]; ok {
78+ if bindingList, ok := bindingValue.([]any); ok {
79+ bindings += len(bindingList)
80+ }
81+ }
82+ }
83+
84+ return groups, bindings, true
85+}
86+
87+func countKeybindingGroups(groups []port.KeybindingGroup) int {
88+ count := 0
89+ for _, group := range groups {
90+ if group.Mode == "" && group.DisplayName == "" && len(group.Bindings) == 0 {
91+ continue
92+ }
93+ count++
94+ }
95+ return count
96+}
97+
98+func countKeybindingBindings(groups []port.KeybindingGroup) int {
99+ count := 0
100+ for _, group := range groups {
101+ count += len(group.Bindings)
102+ }
103+ return count
104+}
105+
106+func plural(count int, singular string) string {
107+ if count == 1 {
108+ return singular
109+ }
110+ return strings.TrimSpace(singular + "s")
111+}

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