detached 98c5923

fix(systemviews): surface bridge load failures

commit
98c5923a5dddc601ef4fc8aec71574f57e84ab1f
author
brice · · 2026-04-24 01:51 UTC
parent
8c27e69
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +122 -4
M assets/systemviews/systemviews.css +16 -0
@@ -121,6 +121,22 @@ body {
121121 color: var(--sv-muted, #9aa3b2);
122122 }
123123
124+.sv-error {
125+ border: 1px solid color-mix(in srgb, #ef4444 65%, var(--sv-border, #2a313d));
126+ background: color-mix(in srgb, #ef4444 12%, var(--sv-surface, #171a21));
127+ border-radius: 0.75rem;
128+ color: var(--sv-fg, #e8ebf0);
129+ padding: 1rem;
130+}
131+
132+.sv-error p {
133+ margin: 0;
134+}
135+
136+.sv-error p + p {
137+ margin-top: 0.5rem;
138+}
139+
124140 .sv-kv {
125141 margin: 0.75rem 0 0;
126142 display: grid;
@@ -121,6 +121,22 @@ body {
121 color: var(--sv-muted, #9aa3b2);121 color: var(--sv-muted, #9aa3b2);
122 }122 }
123 123
124+.sv-error {
125+ border: 1px solid color-mix(in srgb, #ef4444 65%, var(--sv-border, #2a313d));
126+ background: color-mix(in srgb, #ef4444 12%, var(--sv-surface, #171a21));
127+ border-radius: 0.75rem;
128+ color: var(--sv-fg, #e8ebf0);
129+ padding: 1rem;
130+}
131+
132+.sv-error p {
133+ margin: 0;
134+}
135+
136+.sv-error p + p {
137+ margin-top: 0.5rem;
138+}
139+
124 .sv-kv {140 .sv-kv {
125 margin: 0.75rem 0 0;141 margin: 0.75rem 0 0;
126 display: grid;142 display: grid;
M internal/infrastructure/systemviewsbridge/client.go +16 -0
@@ -6,6 +6,7 @@ import (
66 "errors"
77 "fmt"
88 "sync/atomic"
9+ "time"
910
1011 "github.com/bnema/dumber/internal/application/port"
1112 "github.com/bnema/dumber/internal/domain/entity"
@@ -29,6 +30,8 @@ var _ port.SystemviewFavoritesService = (*Client)(nil)
2930
3031 var requestSeq atomic.Uint64
3132
33+var bridgeRequestTimeout = 15 * time.Second
34+
3235 // NewClient creates a bridge client with native WebKit and fetch transports.
3336 func NewClient(native, fetch Transport) *Client {
3437 return &Client{native: native, fetch: fetch}
@@ -284,6 +287,9 @@ func (c *Client) transport() Transport {
284287 func request[T any](c *Client, ctx context.Context, msgType string, payload any) (T, error) {
285288 var zero T
286289
290+ ctx, cancel := withBridgeRequestTimeout(ctx)
291+ defer cancel()
292+
287293 raw, err := c.Send(ctx, msgType, payload)
288294 if err != nil {
289295 return zero, err
@@ -292,6 +298,16 @@ func request[T any](c *Client, ctx context.Context, msgType string, payload any)
292298 return decodeBridgeResponse[T](raw)
293299 }
294300
301+func withBridgeRequestTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
302+ if ctx == nil {
303+ ctx = context.Background()
304+ }
305+ if _, ok := ctx.Deadline(); ok || bridgeRequestTimeout <= 0 {
306+ return context.WithCancel(ctx)
307+ }
308+ return context.WithTimeout(ctx, bridgeRequestTimeout)
309+}
310+
295311 type bridgeResponse struct {
296312 RequestID string `json:"requestId"`
297313 Success bool `json:"success"`
@@ -6,6 +6,7 @@ import (
6 "errors"6 "errors"
7 "fmt"7 "fmt"
8 "sync/atomic"8 "sync/atomic"
9+ "time"
9 10
10 "github.com/bnema/dumber/internal/application/port"11 "github.com/bnema/dumber/internal/application/port"
11 "github.com/bnema/dumber/internal/domain/entity"12 "github.com/bnema/dumber/internal/domain/entity"
@@ -29,6 +30,8 @@ var _ port.SystemviewFavoritesService = (*Client)(nil)
29 30
30 var requestSeq atomic.Uint6431 var requestSeq atomic.Uint64
31 32
33+var bridgeRequestTimeout = 15 * time.Second
34+
32 // NewClient creates a bridge client with native WebKit and fetch transports.35 // NewClient creates a bridge client with native WebKit and fetch transports.
33 func NewClient(native, fetch Transport) *Client {36 func NewClient(native, fetch Transport) *Client {
34 return &Client{native: native, fetch: fetch}37 return &Client{native: native, fetch: fetch}
@@ -284,6 +287,9 @@ func (c *Client) transport() Transport {
284 func request[T any](c *Client, ctx context.Context, msgType string, payload any) (T, error) {287 func request[T any](c *Client, ctx context.Context, msgType string, payload any) (T, error) {
285 var zero T288 var zero T
286 289
290+ ctx, cancel := withBridgeRequestTimeout(ctx)
291+ defer cancel()
292+
287 raw, err := c.Send(ctx, msgType, payload)293 raw, err := c.Send(ctx, msgType, payload)
288 if err != nil {294 if err != nil {
289 return zero, err295 return zero, err
@@ -292,6 +298,16 @@ func request[T any](c *Client, ctx context.Context, msgType string, payload any)
292 return decodeBridgeResponse[T](raw)298 return decodeBridgeResponse[T](raw)
293 }299 }
294 300
301+func withBridgeRequestTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
302+ if ctx == nil {
303+ ctx = context.Background()
304+ }
305+ if _, ok := ctx.Deadline(); ok || bridgeRequestTimeout <= 0 {
306+ return context.WithCancel(ctx)
307+ }
308+ return context.WithTimeout(ctx, bridgeRequestTimeout)
309+}
310+
295 type bridgeResponse struct {311 type bridgeResponse struct {
296 RequestID string `json:"requestId"`312 RequestID string `json:"requestId"`
297 Success bool `json:"success"`313 Success bool `json:"success"`
M internal/infrastructure/systemviewsbridge/client_test.go +32 -0
@@ -3,7 +3,9 @@ package systemviewsbridge
33 import (
44 "context"
55 "encoding/json"
6+ "errors"
67 "testing"
8+ "time"
79
810 "github.com/bnema/dumber/internal/application/port"
911 )
@@ -356,6 +358,23 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) {
356358 }
357359 }
358360
361+func TestClientRequestUsesDefaultTimeoutWhenCallerHasNoDeadline(t *testing.T) {
362+ oldTimeout := bridgeRequestTimeout
363+ bridgeRequestTimeout = time.Millisecond
364+ t.Cleanup(func() { bridgeRequestTimeout = oldTimeout })
365+
366+ transport := &blockingTransport{available: true}
367+ client := NewClient(transport, nil)
368+
369+ _, err := client.Timeline(context.Background(), 25, 0)
370+ if !errors.Is(err, context.DeadlineExceeded) {
371+ t.Fatalf("Timeline() error = %v, want context deadline exceeded", err)
372+ }
373+ if !transport.called {
374+ t.Fatal("transport was not called")
375+ }
376+}
377+
359378 func TestClientCreateFolderIncludesParentID(t *testing.T) {
360379 t.Parallel()
361380
@@ -422,3 +441,16 @@ func (t *fakeTransport) Send(_ context.Context, body []byte) ([]byte, error) {
422441 t.last = append(t.last[:0], body...)
423442 return t.response, nil
424443 }
444+
445+type blockingTransport struct {
446+ available bool
447+ called bool
448+}
449+
450+func (t *blockingTransport) Available() bool { return t.available }
451+
452+func (t *blockingTransport) Send(ctx context.Context, _ []byte) ([]byte, error) {
453+ t.called = true
454+ <-ctx.Done()
455+ return nil, ctx.Err()
456+}
@@ -3,7 +3,9 @@ package systemviewsbridge
3 import (3 import (
4 "context"4 "context"
5 "encoding/json"5 "encoding/json"
6+ "errors"
6 "testing"7 "testing"
8+ "time"
7 9
8 "github.com/bnema/dumber/internal/application/port"10 "github.com/bnema/dumber/internal/application/port"
9 )11 )
@@ -356,6 +358,23 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) {
356 }358 }
357 }359 }
358 360
361+func TestClientRequestUsesDefaultTimeoutWhenCallerHasNoDeadline(t *testing.T) {
362+ oldTimeout := bridgeRequestTimeout
363+ bridgeRequestTimeout = time.Millisecond
364+ t.Cleanup(func() { bridgeRequestTimeout = oldTimeout })
365+
366+ transport := &blockingTransport{available: true}
367+ client := NewClient(transport, nil)
368+
369+ _, err := client.Timeline(context.Background(), 25, 0)
370+ if !errors.Is(err, context.DeadlineExceeded) {
371+ t.Fatalf("Timeline() error = %v, want context deadline exceeded", err)
372+ }
373+ if !transport.called {
374+ t.Fatal("transport was not called")
375+ }
376+}
377+
359 func TestClientCreateFolderIncludesParentID(t *testing.T) {378 func TestClientCreateFolderIncludesParentID(t *testing.T) {
360 t.Parallel()379 t.Parallel()
361 380
@@ -422,3 +441,16 @@ func (t *fakeTransport) Send(_ context.Context, body []byte) ([]byte, error) {
422 t.last = append(t.last[:0], body...)441 t.last = append(t.last[:0], body...)
423 return t.response, nil442 return t.response, nil
424 }443 }
444+
445+type blockingTransport struct {
446+ available bool
447+ called bool
448+}
449+
450+func (t *blockingTransport) Available() bool { return t.available }
451+
452+func (t *blockingTransport) Send(ctx context.Context, _ []byte) ([]byte, error) {
453+ t.called = true
454+ <-ctx.Done()
455+ return nil, ctx.Err()
456+}
M internal/ui/systemviews/app.go +34 -3
@@ -60,13 +60,14 @@ func (a *App) LoadInitial(ctx context.Context) error {
6060 a.currentRoute = ParseRoute(a.deps.LocationURI)
6161 }
6262
63+ var err error
6364 switch a.currentRoute {
6465 case RouteHistory:
65- return a.loadHistoryRoute(ctx)
66+ err = a.loadHistoryRoute(ctx)
6667 case RouteFavorites:
67- return a.loadFavoritesRoute(ctx)
68+ err = a.loadFavoritesRoute(ctx)
6869 case RouteConfig:
69- return a.loadConfigRoute(ctx)
70+ err = a.loadConfigRoute(ctx)
7071 default:
7172 a.resetRouteState()
7273 a.renderedHTML = renderAppFrame(renderedPage{
@@ -76,6 +77,36 @@ func (a *App) LoadInitial(ctx context.Context) error {
7677 }, a.shellTheme)
7778 return nil
7879 }
80+ if err != nil {
81+ a.renderRouteError(err)
82+ }
83+ return nil
84+}
85+
86+func (a *App) renderRouteError(err error) {
87+ a.resetRouteState()
88+ message := "unknown error"
89+ if err != nil {
90+ message = err.Error()
91+ }
92+ a.renderedHTML = renderAppFrame(renderedPage{
93+ route: a.currentRoute,
94+ subtitle: routeSubtitle(a.currentRoute),
95+ body: errorStateHTML(message),
96+ }, a.shellTheme)
97+}
98+
99+func routeSubtitle(route Route) string {
100+ switch route {
101+ case RouteHistory:
102+ return "Recent visits"
103+ case RouteFavorites:
104+ return "Saved bookmarks"
105+ case RouteConfig:
106+ return "Browser settings"
107+ default:
108+ return string(route)
109+ }
79110 }
80111
81112 func (a *App) loadHistoryRoute(ctx context.Context) error {
@@ -60,13 +60,14 @@ func (a *App) LoadInitial(ctx context.Context) error {
60 a.currentRoute = ParseRoute(a.deps.LocationURI)60 a.currentRoute = ParseRoute(a.deps.LocationURI)
61 }61 }
62 62
63+ var err error
63 switch a.currentRoute {64 switch a.currentRoute {
64 case RouteHistory:65 case RouteHistory:
65- return a.loadHistoryRoute(ctx)66+ err = a.loadHistoryRoute(ctx)
66 case RouteFavorites:67 case RouteFavorites:
67- return a.loadFavoritesRoute(ctx)68+ err = a.loadFavoritesRoute(ctx)
68 case RouteConfig:69 case RouteConfig:
69- return a.loadConfigRoute(ctx)70+ err = a.loadConfigRoute(ctx)
70 default:71 default:
71 a.resetRouteState()72 a.resetRouteState()
72 a.renderedHTML = renderAppFrame(renderedPage{73 a.renderedHTML = renderAppFrame(renderedPage{
@@ -76,6 +77,36 @@ func (a *App) LoadInitial(ctx context.Context) error {
76 }, a.shellTheme)77 }, a.shellTheme)
77 return nil78 return nil
78 }79 }
80+ if err != nil {
81+ a.renderRouteError(err)
82+ }
83+ return nil
84+}
85+
86+func (a *App) renderRouteError(err error) {
87+ a.resetRouteState()
88+ message := "unknown error"
89+ if err != nil {
90+ message = err.Error()
91+ }
92+ a.renderedHTML = renderAppFrame(renderedPage{
93+ route: a.currentRoute,
94+ subtitle: routeSubtitle(a.currentRoute),
95+ body: errorStateHTML(message),
96+ }, a.shellTheme)
97+}
98+
99+func routeSubtitle(route Route) string {
100+ switch route {
101+ case RouteHistory:
102+ return "Recent visits"
103+ case RouteFavorites:
104+ return "Saved bookmarks"
105+ case RouteConfig:
106+ return "Browser settings"
107+ default:
108+ return string(route)
109+ }
79 }110 }
80 111
81 func (a *App) loadHistoryRoute(ctx context.Context) error {112 func (a *App) loadHistoryRoute(ctx context.Context) error {
M internal/ui/systemviews/app_test.go +20 -1
@@ -2,6 +2,7 @@ package systemviews
22
33 import (
44 "context"
5+ "errors"
56 "testing"
67
78 "github.com/bnema/dumber/internal/application/port"
@@ -84,6 +85,23 @@ func TestAppLoadInitialHistoryRouteUsesStyledSections(t *testing.T) {
8485 assert.Contains(t, app.renderedHTML, `class="sv-list"`)
8586 }
8687
88+func TestAppLoadInitialHistoryRouteRendersErrorState(t *testing.T) {
89+ t.Parallel()
90+
91+ history := &fakeHistoryService{err: errors.New("database unavailable")}
92+ app := NewApp(Dependencies{
93+ History: history,
94+ LocationURI: "dumb://history",
95+ })
96+
97+ require.NoError(t, app.LoadInitial(context.Background()))
98+ assert.Equal(t, RouteHistory, app.CurrentRoute())
99+ assert.True(t, history.called)
100+ assert.Contains(t, app.renderedHTML, "Could not load this system view")
101+ assert.Contains(t, app.renderedHTML, "database unavailable")
102+ assert.Contains(t, app.renderedHTML, `role="alert"`)
103+}
104+
87105 func TestAppLoadInitialHistoryRouteAppliesThemeTokens(t *testing.T) {
88106 t.Parallel()
89107
@@ -319,13 +337,14 @@ type fakeHistoryService struct {
319337 limit int
320338 offset int
321339 entries []*entity.HistoryEntry
340+ err error
322341 }
323342
324343 func (s *fakeHistoryService) Timeline(_ context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
325344 s.called = true
326345 s.limit = limit
327346 s.offset = offset
328- return s.entries, nil
347+ return s.entries, s.err
329348 }
330349
331350 func (s *fakeHistoryService) Search(context.Context, string, int) ([]*entity.HistoryEntry, error) {
@@ -2,6 +2,7 @@ package systemviews
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "testing"6 "testing"
6 7
7 "github.com/bnema/dumber/internal/application/port"8 "github.com/bnema/dumber/internal/application/port"
@@ -84,6 +85,23 @@ func TestAppLoadInitialHistoryRouteUsesStyledSections(t *testing.T) {
84 assert.Contains(t, app.renderedHTML, `class="sv-list"`)85 assert.Contains(t, app.renderedHTML, `class="sv-list"`)
85 }86 }
86 87
88+func TestAppLoadInitialHistoryRouteRendersErrorState(t *testing.T) {
89+ t.Parallel()
90+
91+ history := &fakeHistoryService{err: errors.New("database unavailable")}
92+ app := NewApp(Dependencies{
93+ History: history,
94+ LocationURI: "dumb://history",
95+ })
96+
97+ require.NoError(t, app.LoadInitial(context.Background()))
98+ assert.Equal(t, RouteHistory, app.CurrentRoute())
99+ assert.True(t, history.called)
100+ assert.Contains(t, app.renderedHTML, "Could not load this system view")
101+ assert.Contains(t, app.renderedHTML, "database unavailable")
102+ assert.Contains(t, app.renderedHTML, `role="alert"`)
103+}
104+
87 func TestAppLoadInitialHistoryRouteAppliesThemeTokens(t *testing.T) {105 func TestAppLoadInitialHistoryRouteAppliesThemeTokens(t *testing.T) {
88 t.Parallel()106 t.Parallel()
89 107
@@ -319,13 +337,14 @@ type fakeHistoryService struct {
319 limit int337 limit int
320 offset int338 offset int
321 entries []*entity.HistoryEntry339 entries []*entity.HistoryEntry
340+ err error
322 }341 }
323 342
324 func (s *fakeHistoryService) Timeline(_ context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {343 func (s *fakeHistoryService) Timeline(_ context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
325 s.called = true344 s.called = true
326 s.limit = limit345 s.limit = limit
327 s.offset = offset346 s.offset = offset
328- return s.entries, nil347+ return s.entries, s.err
329 }348 }
330 349
331 func (s *fakeHistoryService) Search(context.Context, string, int) ([]*entity.HistoryEntry, error) {350 func (s *fakeHistoryService) Search(context.Context, string, int) ([]*entity.HistoryEntry, error) {
M internal/ui/systemviews/shell.go +4 -0
@@ -109,3 +109,7 @@ func sectionHTML(class, title, inner string) string {
109109 func emptyStateHTML(message string) string {
110110 return fmt.Sprintf(`<div class="sv-empty"><p>%s</p></div>`, html.EscapeString(message))
111111 }
112+
113+func errorStateHTML(message string) string {
114+ return fmt.Sprintf(`<div class="sv-error" role="alert"><p>Could not load this system view.</p><p class="sv-meta">%s</p></div>`, html.EscapeString(message))
115+}
@@ -109,3 +109,7 @@ func sectionHTML(class, title, inner string) string {
109 func emptyStateHTML(message string) string {109 func emptyStateHTML(message string) string {
110 return fmt.Sprintf(`<div class="sv-empty"><p>%s</p></div>`, html.EscapeString(message))110 return fmt.Sprintf(`<div class="sv-empty"><p>%s</p></div>`, html.EscapeString(message))
111 }111 }
112+
113+func errorStateHTML(message string) string {
114+ return fmt.Sprintf(`<div class="sv-error" role="alert"><p>Could not load this system view.</p><p class="sv-meta">%s</p></div>`, html.EscapeString(message))
115+}

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