detached 0355bc1

feat(systemviews): add wasm bridge contracts

commit
0355bc1d7a604a01939aa11c4bca1916f6fa5c50
author
brice · · 2026-04-11 18:10 UTC
committer
brice · · 2026-04-12 04:52 UTC
parent
0b331d7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +239 -0
A internal/application/port/systemviews.go +46 -0
@@ -0,0 +1,46 @@
1+package port
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+)
8+
9+// SystemviewHistoryService exposes history operations for the systemviews UI.
10+type SystemviewHistoryService interface {
11+ Timeline(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
12+ Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error)
13+ DeleteEntry(ctx context.Context, id int64) error
14+ DeleteRange(ctx context.Context, rangeID string) error
15+ Analytics(ctx context.Context) (*entity.HistoryAnalytics, error)
16+ DomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error)
17+ DeleteDomain(ctx context.Context, domain string) error
18+}
19+
20+// SystemviewFavoritesService exposes favorite, folder, and tag operations.
21+type SystemviewFavoritesService interface {
22+ List(ctx context.Context) ([]*entity.Favorite, error)
23+ ListFolders(ctx context.Context) ([]*entity.Folder, error)
24+ ListTags(ctx context.Context) ([]*entity.Tag, error)
25+ SetShortcut(ctx context.Context, favoriteID int64, shortcutKey *int) error
26+ SetFolder(ctx context.Context, favoriteID int64, folderID *int64) error
27+ CreateFolder(ctx context.Context, name string, parentID *int64) (*entity.Folder, error)
28+ UpdateFolder(ctx context.Context, id int64, name, icon string) error
29+ DeleteFolder(ctx context.Context, id int64) error
30+ CreateTag(ctx context.Context, name, color string) (*entity.Tag, error)
31+ UpdateTag(ctx context.Context, id int64, name, color string) error
32+ DeleteTag(ctx context.Context, id int64) error
33+ AssignTag(ctx context.Context, favoriteID, tagID int64) error
34+ RemoveTag(ctx context.Context, favoriteID, tagID int64) error
35+}
36+
37+// SystemviewConfigService exposes config and keybinding operations for the systemviews UI.
38+type SystemviewConfigService interface {
39+ Current(ctx context.Context) (SystemviewConfigPayload, error)
40+ Default(ctx context.Context) (SystemviewConfigPayload, error)
41+ Save(ctx context.Context, cfg WebUIConfig) error
42+ GetKeybindings(ctx context.Context) (any, error)
43+ SetKeybinding(ctx context.Context, req SetKeybindingRequest) (any, error)
44+ ResetKeybinding(ctx context.Context, req ResetKeybindingRequest) error
45+ ResetAllKeybindings(ctx context.Context) error
46+}
@@ -0,0 +1,46 @@
1+package port
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+)
8+
9+// SystemviewHistoryService exposes history operations for the systemviews UI.
10+type SystemviewHistoryService interface {
11+ Timeline(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
12+ Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error)
13+ DeleteEntry(ctx context.Context, id int64) error
14+ DeleteRange(ctx context.Context, rangeID string) error
15+ Analytics(ctx context.Context) (*entity.HistoryAnalytics, error)
16+ DomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error)
17+ DeleteDomain(ctx context.Context, domain string) error
18+}
19+
20+// SystemviewFavoritesService exposes favorite, folder, and tag operations.
21+type SystemviewFavoritesService interface {
22+ List(ctx context.Context) ([]*entity.Favorite, error)
23+ ListFolders(ctx context.Context) ([]*entity.Folder, error)
24+ ListTags(ctx context.Context) ([]*entity.Tag, error)
25+ SetShortcut(ctx context.Context, favoriteID int64, shortcutKey *int) error
26+ SetFolder(ctx context.Context, favoriteID int64, folderID *int64) error
27+ CreateFolder(ctx context.Context, name string, parentID *int64) (*entity.Folder, error)
28+ UpdateFolder(ctx context.Context, id int64, name, icon string) error
29+ DeleteFolder(ctx context.Context, id int64) error
30+ CreateTag(ctx context.Context, name, color string) (*entity.Tag, error)
31+ UpdateTag(ctx context.Context, id int64, name, color string) error
32+ DeleteTag(ctx context.Context, id int64) error
33+ AssignTag(ctx context.Context, favoriteID, tagID int64) error
34+ RemoveTag(ctx context.Context, favoriteID, tagID int64) error
35+}
36+
37+// SystemviewConfigService exposes config and keybinding operations for the systemviews UI.
38+type SystemviewConfigService interface {
39+ Current(ctx context.Context) (SystemviewConfigPayload, error)
40+ Default(ctx context.Context) (SystemviewConfigPayload, error)
41+ Save(ctx context.Context, cfg WebUIConfig) error
42+ GetKeybindings(ctx context.Context) (any, error)
43+ SetKeybinding(ctx context.Context, req SetKeybindingRequest) (any, error)
44+ ResetKeybinding(ctx context.Context, req ResetKeybindingRequest) error
45+ ResetAllKeybindings(ctx context.Context) error
46+}
A internal/infrastructure/systemviewsbridge/client.go +82 -0
@@ -0,0 +1,82 @@
1+package systemviewsbridge
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+)
11+
12+// Transport sends a WebUI message envelope and returns the raw response body.
13+type Transport interface {
14+ Available() bool
15+ Send(ctx context.Context, body []byte) ([]byte, error)
16+}
17+
18+// Client sends WebUI message envelopes through the first available transport.
19+type Client struct {
20+ native Transport
21+ fetch Transport
22+}
23+
24+// NewClient creates a bridge client with native WebKit and fetch transports.
25+func NewClient(native, fetch Transport) *Client {
26+ return &Client{native: native, fetch: fetch}
27+}
28+
29+// Send builds a WebUI message envelope and sends it through the active transport.
30+func (c *Client) Send(ctx context.Context, msgType string, payload any) ([]byte, error) {
31+ if c == nil {
32+ return nil, errors.New("bridge client is nil")
33+ }
34+
35+ envelope, err := buildMessageEnvelope(msgType, payload)
36+ if err != nil {
37+ return nil, err
38+ }
39+
40+ transport := c.transport()
41+ if transport == nil {
42+ return nil, errors.New("no bridge transport available")
43+ }
44+
45+ return transport.Send(ctx, envelope)
46+}
47+
48+func (c *Client) transport() Transport {
49+ if c == nil {
50+ return nil
51+ }
52+ if c.native != nil && c.native.Available() {
53+ return c.native
54+ }
55+ if c.fetch != nil {
56+ return c.fetch
57+ }
58+ return nil
59+}
60+
61+func buildMessageEnvelope(msgType string, payload any) ([]byte, error) {
62+ if msgType == "" {
63+ return nil, errors.New("message type cannot be empty")
64+ }
65+
66+ body, err := json.Marshal(payload)
67+ if err != nil {
68+ return nil, fmt.Errorf("marshal payload: %w", err)
69+ }
70+
71+ envelope := port.WebUIMessage{
72+ Type: msgType,
73+ Payload: body,
74+ }
75+
76+ data, err := json.Marshal(envelope)
77+ if err != nil {
78+ return nil, fmt.Errorf("marshal envelope: %w", err)
79+ }
80+
81+ return data, nil
82+}
@@ -0,0 +1,82 @@
1+package systemviewsbridge
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+)
11+
12+// Transport sends a WebUI message envelope and returns the raw response body.
13+type Transport interface {
14+ Available() bool
15+ Send(ctx context.Context, body []byte) ([]byte, error)
16+}
17+
18+// Client sends WebUI message envelopes through the first available transport.
19+type Client struct {
20+ native Transport
21+ fetch Transport
22+}
23+
24+// NewClient creates a bridge client with native WebKit and fetch transports.
25+func NewClient(native, fetch Transport) *Client {
26+ return &Client{native: native, fetch: fetch}
27+}
28+
29+// Send builds a WebUI message envelope and sends it through the active transport.
30+func (c *Client) Send(ctx context.Context, msgType string, payload any) ([]byte, error) {
31+ if c == nil {
32+ return nil, errors.New("bridge client is nil")
33+ }
34+
35+ envelope, err := buildMessageEnvelope(msgType, payload)
36+ if err != nil {
37+ return nil, err
38+ }
39+
40+ transport := c.transport()
41+ if transport == nil {
42+ return nil, errors.New("no bridge transport available")
43+ }
44+
45+ return transport.Send(ctx, envelope)
46+}
47+
48+func (c *Client) transport() Transport {
49+ if c == nil {
50+ return nil
51+ }
52+ if c.native != nil && c.native.Available() {
53+ return c.native
54+ }
55+ if c.fetch != nil {
56+ return c.fetch
57+ }
58+ return nil
59+}
60+
61+func buildMessageEnvelope(msgType string, payload any) ([]byte, error) {
62+ if msgType == "" {
63+ return nil, errors.New("message type cannot be empty")
64+ }
65+
66+ body, err := json.Marshal(payload)
67+ if err != nil {
68+ return nil, fmt.Errorf("marshal payload: %w", err)
69+ }
70+
71+ envelope := port.WebUIMessage{
72+ Type: msgType,
73+ Payload: body,
74+ }
75+
76+ data, err := json.Marshal(envelope)
77+ if err != nil {
78+ return nil, fmt.Errorf("marshal envelope: %w", err)
79+ }
80+
81+ return data, nil
82+}
A internal/infrastructure/systemviewsbridge/client_test.go +111 -0
@@ -0,0 +1,111 @@
1+package systemviewsbridge
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+)
10+
11+func TestBuildMessageEnvelope(t *testing.T) {
12+ t.Parallel()
13+
14+ payload := struct {
15+ RequestID string `json:"requestId"`
16+ Limit int `json:"limit"`
17+ }{RequestID: "req-1", Limit: 25}
18+
19+ got, err := buildMessageEnvelope("history_timeline", payload)
20+ if err != nil {
21+ t.Fatalf("buildMessageEnvelope() error = %v", err)
22+ }
23+
24+ var msg port.WebUIMessage
25+ if err := json.Unmarshal(got, &msg); err != nil {
26+ t.Fatalf("unmarshal envelope: %v", err)
27+ }
28+
29+ if msg.Type != "history_timeline" {
30+ t.Fatalf("msg.Type = %q, want %q", msg.Type, "history_timeline")
31+ }
32+
33+ if gotPayload := string(msg.Payload); gotPayload != `{"requestId":"req-1","limit":25}` {
34+ t.Fatalf("msg.Payload = %s, want %s", gotPayload, `{"requestId":"req-1","limit":25}`)
35+ }
36+}
37+
38+func TestClientSendPrefersNativeTransport(t *testing.T) {
39+ t.Parallel()
40+
41+ native := &fakeTransport{available: true, response: []byte(`{"transport":"native"}`)}
42+ fetch := &fakeTransport{available: true, response: []byte(`{"transport":"fetch"}`)}
43+
44+ client := NewClient(native, fetch)
45+ got, err := client.Send(context.Background(), "favorite_list", struct {
46+ RequestID string `json:"requestId"`
47+ }{RequestID: "req-2"})
48+ if err != nil {
49+ t.Fatalf("Send() error = %v", err)
50+ }
51+
52+ if string(got) != `{"transport":"native"}` {
53+ t.Fatalf("Send() = %s, want %s", got, `{"transport":"native"}`)
54+ }
55+
56+ if !native.called {
57+ t.Fatal("native transport was not used")
58+ }
59+ if fetch.called {
60+ t.Fatal("fetch transport was used unexpectedly")
61+ }
62+
63+ var msg port.WebUIMessage
64+ if err := json.Unmarshal(native.last, &msg); err != nil {
65+ t.Fatalf("unmarshal sent envelope: %v", err)
66+ }
67+ if msg.Type != "favorite_list" {
68+ t.Fatalf("sent type = %q, want %q", msg.Type, "favorite_list")
69+ }
70+}
71+
72+func TestClientSendFallsBackToFetchTransport(t *testing.T) {
73+ t.Parallel()
74+
75+ native := &fakeTransport{available: false, response: []byte(`{"transport":"native"}`)}
76+ fetch := &fakeTransport{available: true, response: []byte(`{"transport":"fetch"}`)}
77+
78+ client := NewClient(native, fetch)
79+ got, err := client.Send(context.Background(), "favorite_list", struct {
80+ RequestID string `json:"requestId"`
81+ }{RequestID: "req-3"})
82+ if err != nil {
83+ t.Fatalf("Send() error = %v", err)
84+ }
85+
86+ if string(got) != `{"transport":"fetch"}` {
87+ t.Fatalf("Send() = %s, want %s", got, `{"transport":"fetch"}`)
88+ }
89+
90+ if native.called {
91+ t.Fatal("native transport was used unexpectedly")
92+ }
93+ if !fetch.called {
94+ t.Fatal("fetch transport was not used")
95+ }
96+}
97+
98+type fakeTransport struct {
99+ available bool
100+ called bool
101+ last []byte
102+ response []byte
103+}
104+
105+func (t *fakeTransport) Available() bool { return t.available }
106+
107+func (t *fakeTransport) Send(_ context.Context, body []byte) ([]byte, error) {
108+ t.called = true
109+ t.last = append(t.last[:0], body...)
110+ return t.response, nil
111+}
@@ -0,0 +1,111 @@
1+package systemviewsbridge
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+)
10+
11+func TestBuildMessageEnvelope(t *testing.T) {
12+ t.Parallel()
13+
14+ payload := struct {
15+ RequestID string `json:"requestId"`
16+ Limit int `json:"limit"`
17+ }{RequestID: "req-1", Limit: 25}
18+
19+ got, err := buildMessageEnvelope("history_timeline", payload)
20+ if err != nil {
21+ t.Fatalf("buildMessageEnvelope() error = %v", err)
22+ }
23+
24+ var msg port.WebUIMessage
25+ if err := json.Unmarshal(got, &msg); err != nil {
26+ t.Fatalf("unmarshal envelope: %v", err)
27+ }
28+
29+ if msg.Type != "history_timeline" {
30+ t.Fatalf("msg.Type = %q, want %q", msg.Type, "history_timeline")
31+ }
32+
33+ if gotPayload := string(msg.Payload); gotPayload != `{"requestId":"req-1","limit":25}` {
34+ t.Fatalf("msg.Payload = %s, want %s", gotPayload, `{"requestId":"req-1","limit":25}`)
35+ }
36+}
37+
38+func TestClientSendPrefersNativeTransport(t *testing.T) {
39+ t.Parallel()
40+
41+ native := &fakeTransport{available: true, response: []byte(`{"transport":"native"}`)}
42+ fetch := &fakeTransport{available: true, response: []byte(`{"transport":"fetch"}`)}
43+
44+ client := NewClient(native, fetch)
45+ got, err := client.Send(context.Background(), "favorite_list", struct {
46+ RequestID string `json:"requestId"`
47+ }{RequestID: "req-2"})
48+ if err != nil {
49+ t.Fatalf("Send() error = %v", err)
50+ }
51+
52+ if string(got) != `{"transport":"native"}` {
53+ t.Fatalf("Send() = %s, want %s", got, `{"transport":"native"}`)
54+ }
55+
56+ if !native.called {
57+ t.Fatal("native transport was not used")
58+ }
59+ if fetch.called {
60+ t.Fatal("fetch transport was used unexpectedly")
61+ }
62+
63+ var msg port.WebUIMessage
64+ if err := json.Unmarshal(native.last, &msg); err != nil {
65+ t.Fatalf("unmarshal sent envelope: %v", err)
66+ }
67+ if msg.Type != "favorite_list" {
68+ t.Fatalf("sent type = %q, want %q", msg.Type, "favorite_list")
69+ }
70+}
71+
72+func TestClientSendFallsBackToFetchTransport(t *testing.T) {
73+ t.Parallel()
74+
75+ native := &fakeTransport{available: false, response: []byte(`{"transport":"native"}`)}
76+ fetch := &fakeTransport{available: true, response: []byte(`{"transport":"fetch"}`)}
77+
78+ client := NewClient(native, fetch)
79+ got, err := client.Send(context.Background(), "favorite_list", struct {
80+ RequestID string `json:"requestId"`
81+ }{RequestID: "req-3"})
82+ if err != nil {
83+ t.Fatalf("Send() error = %v", err)
84+ }
85+
86+ if string(got) != `{"transport":"fetch"}` {
87+ t.Fatalf("Send() = %s, want %s", got, `{"transport":"fetch"}`)
88+ }
89+
90+ if native.called {
91+ t.Fatal("native transport was used unexpectedly")
92+ }
93+ if !fetch.called {
94+ t.Fatal("fetch transport was not used")
95+ }
96+}
97+
98+type fakeTransport struct {
99+ available bool
100+ called bool
101+ last []byte
102+ response []byte
103+}
104+
105+func (t *fakeTransport) Available() bool { return t.available }
106+
107+func (t *fakeTransport) Send(_ context.Context, body []byte) ([]byte, error) {
108+ t.called = true
109+ t.last = append(t.last[:0], body...)
110+ return t.response, nil
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