detached 0224ed2

feat(systemviews): add history route

commit
0224ed2785bb6261fe5b96598dead19abfd7b38f
author
brice · · 2026-04-11 18:50 UTC
committer
brice · · 2026-04-12 04:52 UTC
parent
940a0b9
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +301 -6
M internal/application/port/systemviews.go +1 -1
@@ -6,7 +6,7 @@ import (
66 "github.com/bnema/dumber/internal/domain/entity"
77 )
88
9-// SystemviewHistoryService exposes history operations for the systemviews UI.
9+// SystemviewHistoryService exposes history operations for the systemviews history route.
1010 type SystemviewHistoryService interface {
1111 Timeline(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
1212 Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error)
@@ -6,7 +6,7 @@ import (
6 "github.com/bnema/dumber/internal/domain/entity"6 "github.com/bnema/dumber/internal/domain/entity"
7 )7 )
8 8
9-// SystemviewHistoryService exposes history operations for the systemviews UI.9+// SystemviewHistoryService exposes history operations for the systemviews history route.
10 type SystemviewHistoryService interface {10 type SystemviewHistoryService interface {
11 Timeline(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)11 Timeline(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
12 Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error)12 Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error)
M internal/infrastructure/systemviewsbridge/client.go +103 -0
@@ -5,8 +5,10 @@ import (
55 "encoding/json"
66 "errors"
77 "fmt"
8+ "sync/atomic"
89
910 "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/domain/entity"
1012 )
1113
1214 // Transport sends a WebUI message envelope and returns the raw response body.
@@ -21,6 +23,8 @@ type Client struct {
2123 fetch Transport
2224 }
2325
26+var requestSeq atomic.Uint64
27+
2428 // NewClient creates a bridge client with native WebKit and fetch transports.
2529 func NewClient(native, fetch Transport) *Client {
2630 return &Client{native: native, fetch: fetch}
@@ -45,6 +49,59 @@ func (c *Client) Send(ctx context.Context, msgType string, payload any) ([]byte,
4549 return transport.Send(ctx, envelope)
4650 }
4751
52+func (c *Client) Timeline(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
53+ return request[[]*entity.HistoryEntry](c, ctx, "history_timeline", struct {
54+ RequestID string `json:"requestId"`
55+ Limit int `json:"limit"`
56+ Offset int `json:"offset"`
57+ }{RequestID: nextRequestID(), Limit: limit, Offset: offset})
58+}
59+
60+func (c *Client) Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error) {
61+ return request[[]*entity.HistoryEntry](c, ctx, "history_search_fts", struct {
62+ RequestID string `json:"requestId"`
63+ Query string `json:"query"`
64+ Limit int `json:"limit"`
65+ }{RequestID: nextRequestID(), Query: query, Limit: limit})
66+}
67+
68+func (c *Client) DeleteEntry(ctx context.Context, id int64) error {
69+ _, err := request[struct{}](c, ctx, "history_delete_entry", struct {
70+ RequestID string `json:"requestId"`
71+ ID int64 `json:"id"`
72+ }{RequestID: nextRequestID(), ID: id})
73+ return err
74+}
75+
76+func (c *Client) DeleteRange(ctx context.Context, rangeID string) error {
77+ _, err := request[struct{}](c, ctx, "history_delete_range", struct {
78+ RequestID string `json:"requestId"`
79+ Range string `json:"range"`
80+ }{RequestID: nextRequestID(), Range: rangeID})
81+ return err
82+}
83+
84+func (c *Client) Analytics(ctx context.Context) (*entity.HistoryAnalytics, error) {
85+ return request[*entity.HistoryAnalytics](c, ctx, "history_analytics", struct {
86+ RequestID string `json:"requestId"`
87+ }{RequestID: nextRequestID()})
88+}
89+
90+func (c *Client) DomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error) {
91+ return request[[]*entity.DomainStat](c, ctx, "history_domain_stats", struct {
92+ RequestID string `json:"requestId"`
93+ Limit int `json:"limit"`
94+ }{RequestID: nextRequestID(), Limit: limit})
95+}
96+
97+func (c *Client) DeleteDomain(ctx context.Context, domain string) error {
98+ _, err := request[struct{}](c, ctx, "history_delete_domain", struct {
99+ RequestID string `json:"requestId"`
100+ Domain string `json:"domain"`
101+ }{RequestID: nextRequestID(), Domain: domain})
102+ return err
103+}
104+
48105 func (c *Client) transport() Transport {
49106 if c == nil {
50107 return nil
@@ -58,6 +115,52 @@ func (c *Client) transport() Transport {
58115 return nil
59116 }
60117
118+func request[T any](c *Client, ctx context.Context, msgType string, payload any) (T, error) {
119+ var zero T
120+
121+ raw, err := c.Send(ctx, msgType, payload)
122+ if err != nil {
123+ return zero, err
124+ }
125+
126+ return decodeBridgeResponse[T](raw)
127+}
128+
129+type bridgeResponse struct {
130+ RequestID string `json:"requestId"`
131+ Success bool `json:"success"`
132+ Data json.RawMessage `json:"data,omitempty"`
133+ Error string `json:"error,omitempty"`
134+}
135+
136+func decodeBridgeResponse[T any](body []byte) (T, error) {
137+ var zero T
138+
139+ var resp bridgeResponse
140+ if err := json.Unmarshal(body, &resp); err != nil {
141+ return zero, fmt.Errorf("unmarshal bridge response: %w", err)
142+ }
143+ if !resp.Success {
144+ if resp.Error == "" {
145+ return zero, errors.New("bridge request failed")
146+ }
147+ return zero, errors.New(resp.Error)
148+ }
149+ if len(resp.Data) == 0 {
150+ return zero, nil
151+ }
152+
153+ if err := json.Unmarshal(resp.Data, &zero); err != nil {
154+ return zero, fmt.Errorf("unmarshal bridge data: %w", err)
155+ }
156+
157+ return zero, nil
158+}
159+
160+func nextRequestID() string {
161+ return fmt.Sprintf("systemviews-%d", requestSeq.Add(1))
162+}
163+
61164 func buildMessageEnvelope(msgType string, payload any) ([]byte, error) {
62165 if msgType == "" {
63166 return nil, errors.New("message type cannot be empty")
@@ -5,8 +5,10 @@ import (
5 "encoding/json"5 "encoding/json"
6 "errors"6 "errors"
7 "fmt"7 "fmt"
8+ "sync/atomic"
8 9
9 "github.com/bnema/dumber/internal/application/port"10 "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/domain/entity"
10 )12 )
11 13
12 // Transport sends a WebUI message envelope and returns the raw response body.14 // Transport sends a WebUI message envelope and returns the raw response body.
@@ -21,6 +23,8 @@ type Client struct {
21 fetch Transport23 fetch Transport
22 }24 }
23 25
26+var requestSeq atomic.Uint64
27+
24 // NewClient creates a bridge client with native WebKit and fetch transports.28 // NewClient creates a bridge client with native WebKit and fetch transports.
25 func NewClient(native, fetch Transport) *Client {29 func NewClient(native, fetch Transport) *Client {
26 return &Client{native: native, fetch: fetch}30 return &Client{native: native, fetch: fetch}
@@ -45,6 +49,59 @@ func (c *Client) Send(ctx context.Context, msgType string, payload any) ([]byte,
45 return transport.Send(ctx, envelope)49 return transport.Send(ctx, envelope)
46 }50 }
47 51
52+func (c *Client) Timeline(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
53+ return request[[]*entity.HistoryEntry](c, ctx, "history_timeline", struct {
54+ RequestID string `json:"requestId"`
55+ Limit int `json:"limit"`
56+ Offset int `json:"offset"`
57+ }{RequestID: nextRequestID(), Limit: limit, Offset: offset})
58+}
59+
60+func (c *Client) Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error) {
61+ return request[[]*entity.HistoryEntry](c, ctx, "history_search_fts", struct {
62+ RequestID string `json:"requestId"`
63+ Query string `json:"query"`
64+ Limit int `json:"limit"`
65+ }{RequestID: nextRequestID(), Query: query, Limit: limit})
66+}
67+
68+func (c *Client) DeleteEntry(ctx context.Context, id int64) error {
69+ _, err := request[struct{}](c, ctx, "history_delete_entry", struct {
70+ RequestID string `json:"requestId"`
71+ ID int64 `json:"id"`
72+ }{RequestID: nextRequestID(), ID: id})
73+ return err
74+}
75+
76+func (c *Client) DeleteRange(ctx context.Context, rangeID string) error {
77+ _, err := request[struct{}](c, ctx, "history_delete_range", struct {
78+ RequestID string `json:"requestId"`
79+ Range string `json:"range"`
80+ }{RequestID: nextRequestID(), Range: rangeID})
81+ return err
82+}
83+
84+func (c *Client) Analytics(ctx context.Context) (*entity.HistoryAnalytics, error) {
85+ return request[*entity.HistoryAnalytics](c, ctx, "history_analytics", struct {
86+ RequestID string `json:"requestId"`
87+ }{RequestID: nextRequestID()})
88+}
89+
90+func (c *Client) DomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error) {
91+ return request[[]*entity.DomainStat](c, ctx, "history_domain_stats", struct {
92+ RequestID string `json:"requestId"`
93+ Limit int `json:"limit"`
94+ }{RequestID: nextRequestID(), Limit: limit})
95+}
96+
97+func (c *Client) DeleteDomain(ctx context.Context, domain string) error {
98+ _, err := request[struct{}](c, ctx, "history_delete_domain", struct {
99+ RequestID string `json:"requestId"`
100+ Domain string `json:"domain"`
101+ }{RequestID: nextRequestID(), Domain: domain})
102+ return err
103+}
104+
48 func (c *Client) transport() Transport {105 func (c *Client) transport() Transport {
49 if c == nil {106 if c == nil {
50 return nil107 return nil
@@ -58,6 +115,52 @@ func (c *Client) transport() Transport {
58 return nil115 return nil
59 }116 }
60 117
118+func request[T any](c *Client, ctx context.Context, msgType string, payload any) (T, error) {
119+ var zero T
120+
121+ raw, err := c.Send(ctx, msgType, payload)
122+ if err != nil {
123+ return zero, err
124+ }
125+
126+ return decodeBridgeResponse[T](raw)
127+}
128+
129+type bridgeResponse struct {
130+ RequestID string `json:"requestId"`
131+ Success bool `json:"success"`
132+ Data json.RawMessage `json:"data,omitempty"`
133+ Error string `json:"error,omitempty"`
134+}
135+
136+func decodeBridgeResponse[T any](body []byte) (T, error) {
137+ var zero T
138+
139+ var resp bridgeResponse
140+ if err := json.Unmarshal(body, &resp); err != nil {
141+ return zero, fmt.Errorf("unmarshal bridge response: %w", err)
142+ }
143+ if !resp.Success {
144+ if resp.Error == "" {
145+ return zero, errors.New("bridge request failed")
146+ }
147+ return zero, errors.New(resp.Error)
148+ }
149+ if len(resp.Data) == 0 {
150+ return zero, nil
151+ }
152+
153+ if err := json.Unmarshal(resp.Data, &zero); err != nil {
154+ return zero, fmt.Errorf("unmarshal bridge data: %w", err)
155+ }
156+
157+ return zero, nil
158+}
159+
160+func nextRequestID() string {
161+ return fmt.Sprintf("systemviews-%d", requestSeq.Add(1))
162+}
163+
61 func buildMessageEnvelope(msgType string, payload any) ([]byte, error) {164 func buildMessageEnvelope(msgType string, payload any) ([]byte, error) {
62 if msgType == "" {165 if msgType == "" {
63 return nil, errors.New("message type cannot be empty")166 return nil, errors.New("message type cannot be empty")
M internal/infrastructure/systemviewsbridge/client_test.go +48 -0
@@ -95,6 +95,54 @@ func TestClientSendFallsBackToFetchTransport(t *testing.T) {
9595 }
9696 }
9797
98+func TestClientTimelineDecodesEntries(t *testing.T) {
99+ t.Parallel()
100+
101+ native := &fakeTransport{available: true, response: []byte(`{"requestId":"req-9","success":true,"data":[{"id":1,"url":"https://example.com","title":"Example"}]}`)}
102+ client := NewClient(native, nil)
103+
104+ entries, err := client.Timeline(context.Background(), 25, 0)
105+ if err != nil {
106+ t.Fatalf("Timeline() error = %v", err)
107+ }
108+ if len(entries) != 1 {
109+ t.Fatalf("Timeline() len = %d, want 1", len(entries))
110+ }
111+ if entries[0].URL != "https://example.com" || entries[0].Title != "Example" {
112+ t.Fatalf("Timeline() entry = %+v", entries[0])
113+ }
114+
115+ var msg port.WebUIMessage
116+ if err := json.Unmarshal(native.last, &msg); err != nil {
117+ t.Fatalf("unmarshal sent envelope: %v", err)
118+ }
119+ if msg.Type != "history_timeline" {
120+ t.Fatalf("sent type = %q, want %q", msg.Type, "history_timeline")
121+ }
122+ if gotPayload := string(msg.Payload); gotPayload == "" {
123+ t.Fatal("sent payload was empty")
124+ }
125+}
126+
127+func TestClientDeleteRangeSendsRange(t *testing.T) {
128+ t.Parallel()
129+
130+ native := &fakeTransport{available: true, response: []byte(`{"requestId":"req-10","success":true}`)}
131+ client := NewClient(native, nil)
132+
133+ if err := client.DeleteRange(context.Background(), "week"); err != nil {
134+ t.Fatalf("DeleteRange() error = %v", err)
135+ }
136+
137+ var msg port.WebUIMessage
138+ if err := json.Unmarshal(native.last, &msg); err != nil {
139+ t.Fatalf("unmarshal sent envelope: %v", err)
140+ }
141+ if msg.Type != "history_delete_range" {
142+ t.Fatalf("sent type = %q, want %q", msg.Type, "history_delete_range")
143+ }
144+}
145+
98146 type fakeTransport struct {
99147 available bool
100148 called bool
@@ -95,6 +95,54 @@ func TestClientSendFallsBackToFetchTransport(t *testing.T) {
95 }95 }
96 }96 }
97 97
98+func TestClientTimelineDecodesEntries(t *testing.T) {
99+ t.Parallel()
100+
101+ native := &fakeTransport{available: true, response: []byte(`{"requestId":"req-9","success":true,"data":[{"id":1,"url":"https://example.com","title":"Example"}]}`)}
102+ client := NewClient(native, nil)
103+
104+ entries, err := client.Timeline(context.Background(), 25, 0)
105+ if err != nil {
106+ t.Fatalf("Timeline() error = %v", err)
107+ }
108+ if len(entries) != 1 {
109+ t.Fatalf("Timeline() len = %d, want 1", len(entries))
110+ }
111+ if entries[0].URL != "https://example.com" || entries[0].Title != "Example" {
112+ t.Fatalf("Timeline() entry = %+v", entries[0])
113+ }
114+
115+ var msg port.WebUIMessage
116+ if err := json.Unmarshal(native.last, &msg); err != nil {
117+ t.Fatalf("unmarshal sent envelope: %v", err)
118+ }
119+ if msg.Type != "history_timeline" {
120+ t.Fatalf("sent type = %q, want %q", msg.Type, "history_timeline")
121+ }
122+ if gotPayload := string(msg.Payload); gotPayload == "" {
123+ t.Fatal("sent payload was empty")
124+ }
125+}
126+
127+func TestClientDeleteRangeSendsRange(t *testing.T) {
128+ t.Parallel()
129+
130+ native := &fakeTransport{available: true, response: []byte(`{"requestId":"req-10","success":true}`)}
131+ client := NewClient(native, nil)
132+
133+ if err := client.DeleteRange(context.Background(), "week"); err != nil {
134+ t.Fatalf("DeleteRange() error = %v", err)
135+ }
136+
137+ var msg port.WebUIMessage
138+ if err := json.Unmarshal(native.last, &msg); err != nil {
139+ t.Fatalf("unmarshal sent envelope: %v", err)
140+ }
141+ if msg.Type != "history_delete_range" {
142+ t.Fatalf("sent type = %q, want %q", msg.Type, "history_delete_range")
143+ }
144+}
145+
98 type fakeTransport struct {146 type fakeTransport struct {
99 available bool147 available bool
100 called bool148 called bool
M internal/ui/systemviews/app.go +47 -5
@@ -1,19 +1,28 @@
11 package systemviews
22
3-import "errors"
3+import (
4+ "context"
5+ "errors"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+)
410
511 type Dependencies struct {
612 DOM DOM
13+ History port.SystemviewHistoryService
714 LocationURI string
815 }
916
1017 type App struct {
11- deps Dependencies
12- currentRoute Route
18+ deps Dependencies
19+ currentRoute Route
20+ historyEntries []*entity.HistoryEntry
21+ renderedHTML string
1322 }
1423
1524 func NewApp(deps Dependencies) *App {
16- return &App{deps: deps}
25+ return &App{deps: deps, currentRoute: RouteUnknown}
1726 }
1827
1928 func (a *App) Run() error {
@@ -22,11 +31,44 @@ func (a *App) Run() error {
2231 }
2332
2433 a.currentRoute = ParseRoute(a.deps.LocationURI)
34+ if err := a.LoadInitial(context.Background()); err != nil {
35+ return err
36+ }
2537 if a.deps.DOM == nil {
2638 return errors.New("DOM not configured")
2739 }
2840
29- return a.deps.DOM.Mount(placeholderHTML(a.currentRoute))
41+ return a.deps.DOM.Mount(a.renderedHTML)
42+}
43+
44+func (a *App) LoadInitial(ctx context.Context) error {
45+ if a == nil {
46+ return errors.New("app is nil")
47+ }
48+ if a.currentRoute == "" || a.currentRoute == RouteUnknown {
49+ a.currentRoute = ParseRoute(a.deps.LocationURI)
50+ }
51+
52+ switch a.currentRoute {
53+ case RouteHistory:
54+ if a.deps.History == nil {
55+ a.historyEntries = nil
56+ a.renderedHTML = placeholderHTML(a.currentRoute)
57+ return nil
58+ }
59+
60+ entries, err := a.deps.History.Timeline(ctx, 25, 0)
61+ if err != nil {
62+ return err
63+ }
64+ a.historyEntries = entries
65+ a.renderedHTML = historyHTML(entries)
66+ return nil
67+ default:
68+ a.historyEntries = nil
69+ a.renderedHTML = placeholderHTML(a.currentRoute)
70+ return nil
71+ }
3072 }
3173
3274 func (a *App) CurrentRoute() Route {
@@ -1,19 +1,28 @@
1 package systemviews1 package systemviews
2 2
3-import "errors"3+import (
4+ "context"
5+ "errors"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+)
4 10
5 type Dependencies struct {11 type Dependencies struct {
6 DOM DOM12 DOM DOM
13+ History port.SystemviewHistoryService
7 LocationURI string14 LocationURI string
8 }15 }
9 16
10 type App struct {17 type App struct {
11- deps Dependencies18+ deps Dependencies
12- currentRoute Route19+ currentRoute Route
20+ historyEntries []*entity.HistoryEntry
21+ renderedHTML string
13 }22 }
14 23
15 func NewApp(deps Dependencies) *App {24 func NewApp(deps Dependencies) *App {
16- return &App{deps: deps}25+ return &App{deps: deps, currentRoute: RouteUnknown}
17 }26 }
18 27
19 func (a *App) Run() error {28 func (a *App) Run() error {
@@ -22,11 +31,44 @@ func (a *App) Run() error {
22 }31 }
23 32
24 a.currentRoute = ParseRoute(a.deps.LocationURI)33 a.currentRoute = ParseRoute(a.deps.LocationURI)
34+ if err := a.LoadInitial(context.Background()); err != nil {
35+ return err
36+ }
25 if a.deps.DOM == nil {37 if a.deps.DOM == nil {
26 return errors.New("DOM not configured")38 return errors.New("DOM not configured")
27 }39 }
28 40
29- return a.deps.DOM.Mount(placeholderHTML(a.currentRoute))41+ return a.deps.DOM.Mount(a.renderedHTML)
42+}
43+
44+func (a *App) LoadInitial(ctx context.Context) error {
45+ if a == nil {
46+ return errors.New("app is nil")
47+ }
48+ if a.currentRoute == "" || a.currentRoute == RouteUnknown {
49+ a.currentRoute = ParseRoute(a.deps.LocationURI)
50+ }
51+
52+ switch a.currentRoute {
53+ case RouteHistory:
54+ if a.deps.History == nil {
55+ a.historyEntries = nil
56+ a.renderedHTML = placeholderHTML(a.currentRoute)
57+ return nil
58+ }
59+
60+ entries, err := a.deps.History.Timeline(ctx, 25, 0)
61+ if err != nil {
62+ return err
63+ }
64+ a.historyEntries = entries
65+ a.renderedHTML = historyHTML(entries)
66+ return nil
67+ default:
68+ a.historyEntries = nil
69+ a.renderedHTML = placeholderHTML(a.currentRoute)
70+ return nil
71+ }
30 }72 }
31 73
32 func (a *App) CurrentRoute() Route {74 func (a *App) CurrentRoute() Route {
M internal/ui/systemviews/app_test.go +57 -0
@@ -1,8 +1,10 @@
11 package systemviews
22
33 import (
4+ "context"
45 "testing"
56
7+ "github.com/bnema/dumber/internal/domain/entity"
68 "github.com/stretchr/testify/assert"
79 "github.com/stretchr/testify/require"
810 )
@@ -49,6 +51,29 @@ func TestAppRunMountsPlaceholderAndRecordsRoute(t *testing.T) {
4951 assert.Contains(t, dom.html, "systemviews")
5052 }
5153
54+func TestAppLoadInitialHistoryRouteRendersEntries(t *testing.T) {
55+ t.Parallel()
56+
57+ history := &fakeHistoryService{entries: []*entity.HistoryEntry{{
58+ URL: "https://example.com",
59+ Title: "Example",
60+ }}}
61+
62+ app := NewApp(Dependencies{
63+ History: history,
64+ LocationURI: "dumb://history",
65+ })
66+
67+ require.NoError(t, app.LoadInitial(context.Background()))
68+ assert.Equal(t, RouteHistory, app.CurrentRoute())
69+ assert.Equal(t, 1, len(app.historyEntries))
70+ assert.Contains(t, app.renderedHTML, "Example")
71+ assert.Contains(t, app.renderedHTML, "https://example.com")
72+ assert.True(t, history.called)
73+ assert.Equal(t, 25, history.limit)
74+ assert.Equal(t, 0, history.offset)
75+}
76+
5277 type fakeDOM struct {
5378 mounted bool
5479 html string
@@ -59,3 +84,35 @@ func (d *fakeDOM) Mount(html string) error {
5984 d.html = html
6085 return nil
6186 }
87+
88+type fakeHistoryService struct {
89+ called bool
90+ limit int
91+ offset int
92+ entries []*entity.HistoryEntry
93+}
94+
95+func (s *fakeHistoryService) Timeline(_ context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
96+ s.called = true
97+ s.limit = limit
98+ s.offset = offset
99+ return s.entries, nil
100+}
101+
102+func (s *fakeHistoryService) Search(context.Context, string, int) ([]*entity.HistoryEntry, error) {
103+ return nil, nil
104+}
105+
106+func (s *fakeHistoryService) DeleteEntry(context.Context, int64) error { return nil }
107+
108+func (s *fakeHistoryService) DeleteRange(context.Context, string) error { return nil }
109+
110+func (s *fakeHistoryService) Analytics(context.Context) (*entity.HistoryAnalytics, error) {
111+ return nil, nil
112+}
113+
114+func (s *fakeHistoryService) DomainStats(context.Context, int) ([]*entity.DomainStat, error) {
115+ return nil, nil
116+}
117+
118+func (s *fakeHistoryService) DeleteDomain(context.Context, string) error { return nil }
@@ -1,8 +1,10 @@
1 package systemviews1 package systemviews
2 2
3 import (3 import (
4+ "context"
4 "testing"5 "testing"
5 6
7+ "github.com/bnema/dumber/internal/domain/entity"
6 "github.com/stretchr/testify/assert"8 "github.com/stretchr/testify/assert"
7 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
8 )10 )
@@ -49,6 +51,29 @@ func TestAppRunMountsPlaceholderAndRecordsRoute(t *testing.T) {
49 assert.Contains(t, dom.html, "systemviews")51 assert.Contains(t, dom.html, "systemviews")
50 }52 }
51 53
54+func TestAppLoadInitialHistoryRouteRendersEntries(t *testing.T) {
55+ t.Parallel()
56+
57+ history := &fakeHistoryService{entries: []*entity.HistoryEntry{{
58+ URL: "https://example.com",
59+ Title: "Example",
60+ }}}
61+
62+ app := NewApp(Dependencies{
63+ History: history,
64+ LocationURI: "dumb://history",
65+ })
66+
67+ require.NoError(t, app.LoadInitial(context.Background()))
68+ assert.Equal(t, RouteHistory, app.CurrentRoute())
69+ assert.Equal(t, 1, len(app.historyEntries))
70+ assert.Contains(t, app.renderedHTML, "Example")
71+ assert.Contains(t, app.renderedHTML, "https://example.com")
72+ assert.True(t, history.called)
73+ assert.Equal(t, 25, history.limit)
74+ assert.Equal(t, 0, history.offset)
75+}
76+
52 type fakeDOM struct {77 type fakeDOM struct {
53 mounted bool78 mounted bool
54 html string79 html string
@@ -59,3 +84,35 @@ func (d *fakeDOM) Mount(html string) error {
59 d.html = html84 d.html = html
60 return nil85 return nil
61 }86 }
87+
88+type fakeHistoryService struct {
89+ called bool
90+ limit int
91+ offset int
92+ entries []*entity.HistoryEntry
93+}
94+
95+func (s *fakeHistoryService) Timeline(_ context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
96+ s.called = true
97+ s.limit = limit
98+ s.offset = offset
99+ return s.entries, nil
100+}
101+
102+func (s *fakeHistoryService) Search(context.Context, string, int) ([]*entity.HistoryEntry, error) {
103+ return nil, nil
104+}
105+
106+func (s *fakeHistoryService) DeleteEntry(context.Context, int64) error { return nil }
107+
108+func (s *fakeHistoryService) DeleteRange(context.Context, string) error { return nil }
109+
110+func (s *fakeHistoryService) Analytics(context.Context) (*entity.HistoryAnalytics, error) {
111+ return nil, nil
112+}
113+
114+func (s *fakeHistoryService) DomainStats(context.Context, int) ([]*entity.DomainStat, error) {
115+ return nil, nil
116+}
117+
118+func (s *fakeHistoryService) DeleteDomain(context.Context, string) error { return nil }
A internal/ui/systemviews/history_view.go +45 -0
@@ -0,0 +1,45 @@
1+package systemviews
2+
3+import (
4+ "fmt"
5+ "html"
6+ "strings"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+)
10+
11+func historyHTML(entries []*entity.HistoryEntry) string {
12+ var items strings.Builder
13+ if len(entries) == 0 {
14+ items.WriteString("<li>No history entries</li>")
15+ } else {
16+ for _, entry := range entries {
17+ if entry == nil {
18+ continue
19+ }
20+ label := entry.Title
21+ if label == "" {
22+ label = entry.URL
23+ }
24+ items.WriteString(fmt.Sprintf(`<li><a href="%s">%s</a></li>`,
25+ html.EscapeString(entry.URL),
26+ html.EscapeString(label),
27+ ))
28+ }
29+ }
30+
31+ return fmt.Sprintf(`<!doctype html>
32+<html lang="en">
33+<head>
34+ <meta charset="utf-8">
35+ <meta name="viewport" content="width=device-width, initial-scale=1">
36+ <title>systemviews history</title>
37+</head>
38+<body>
39+ <main id="app" data-route="history">
40+ <h1>History</h1>
41+ <ul>%s</ul>
42+ </main>
43+</body>
44+</html>`, items.String())
45+}
@@ -0,0 +1,45 @@
1+package systemviews
2+
3+import (
4+ "fmt"
5+ "html"
6+ "strings"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+)
10+
11+func historyHTML(entries []*entity.HistoryEntry) string {
12+ var items strings.Builder
13+ if len(entries) == 0 {
14+ items.WriteString("<li>No history entries</li>")
15+ } else {
16+ for _, entry := range entries {
17+ if entry == nil {
18+ continue
19+ }
20+ label := entry.Title
21+ if label == "" {
22+ label = entry.URL
23+ }
24+ items.WriteString(fmt.Sprintf(`<li><a href="%s">%s</a></li>`,
25+ html.EscapeString(entry.URL),
26+ html.EscapeString(label),
27+ ))
28+ }
29+ }
30+
31+ return fmt.Sprintf(`<!doctype html>
32+<html lang="en">
33+<head>
34+ <meta charset="utf-8">
35+ <meta name="viewport" content="width=device-width, initial-scale=1">
36+ <title>systemviews history</title>
37+</head>
38+<body>
39+ <main id="app" data-route="history">
40+ <h1>History</h1>
41+ <ul>%s</ul>
42+ </main>
43+</body>
44+</html>`, items.String())
45+}

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