detached 95983dd

fix(systemviews): address follow-up review feedback

commit
95983dd3ef183892c242f896dbe8b0fcb8e4bae2
author
brice · · 2026-04-26 06:40 UTC
parent
1cb3bf3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
47 files · +601 -415
M assets/systemviews/systemviews.css +6 -0
@@ -799,6 +799,12 @@ body {
799799 min-width: 4.75rem;
800800 }
801801
802+.sv-history-row-actions .sv-button-icon-only {
803+ width: 2rem;
804+ min-width: 2rem;
805+ flex: 0 0 2rem;
806+}
807+
802808 .sv-button-active {
803809 border-color: var(--sv-accent, #8ab4ff);
804810 box-shadow: 0 0 0 1px var(--sv-accent, #8ab4ff) inset;
@@ -799,6 +799,12 @@ body {
799 min-width: 4.75rem;799 min-width: 4.75rem;
800 }800 }
801 801
802+.sv-history-row-actions .sv-button-icon-only {
803+ width: 2rem;
804+ min-width: 2rem;
805+ flex: 0 0 2rem;
806+}
807+
802 .sv-button-active {808 .sv-button-active {
803 border-color: var(--sv-accent, #8ab4ff);809 border-color: var(--sv-accent, #8ab4ff);
804 box-shadow: 0 0 0 1px var(--sv-accent, #8ab4ff) inset;810 box-shadow: 0 0 0 1px var(--sv-accent, #8ab4ff) inset;
M cmd/systemviews/bootstrap_test.go +36 -4
@@ -2,6 +2,7 @@ package main
22
33 import (
44 "context"
5+ "strings"
56 "testing"
67 "time"
78
@@ -45,15 +46,16 @@ func TestNewBridgeApp_UsesCurrentConfigForNonConfigRoutes(t *testing.T) {
4546 },
4647 },
4748 }
48- dom := &fakeDOM{}
49+ dom := &fakeDOM{mounts: make(chan string, 4)}
4950 app := newBridgeApp(dom, "dumb://history", bridge)
5051
5152 require.NoError(t, app.Run())
5253 require.Eventually(t, func() bool { return bridge.calledHistory }, time.Second, 10*time.Millisecond)
5354 assert.True(t, bridge.calledConfig)
5455 assert.False(t, bridge.calledKeybindings)
55- assert.Contains(t, dom.html, `class="sv-app sv-dark"`)
56- assert.Contains(t, dom.html, `--sv-background: #111111;`)
56+ html := receiveMountContaining(t, dom.mounts, `class="sv-app sv-dark"`, `--sv-background: #111111;`)
57+ assert.Contains(t, html, `class="sv-app sv-dark"`)
58+ assert.Contains(t, html, `--sv-background: #111111;`)
5759 }
5860
5961 func TestNewBridgeApp_WiresFavoritesService(t *testing.T) {
@@ -90,14 +92,44 @@ func TestNewBridgeApp_WiresConfigService(t *testing.T) {
9092 }
9193
9294 type fakeDOM struct {
93- html string
95+ html string
96+ mounts chan string
9497 }
9598
9699 func (f *fakeDOM) Mount(html string) error {
97100 f.html = html
101+ if f.mounts != nil {
102+ select {
103+ case f.mounts <- html:
104+ default:
105+ }
106+ }
98107 return nil
99108 }
100109
110+func receiveMountContaining(t *testing.T, mounts <-chan string, values ...string) string {
111+ t.Helper()
112+ deadline := time.After(time.Second)
113+ for {
114+ select {
115+ case html := <-mounts:
116+ matched := true
117+ for _, value := range values {
118+ if !strings.Contains(html, value) {
119+ matched = false
120+ break
121+ }
122+ }
123+ if matched {
124+ return html
125+ }
126+ case <-deadline:
127+ t.Fatalf("timed out waiting for DOM mount containing %q", values)
128+ return ""
129+ }
130+ }
131+}
132+
101133 // Handwritten fake intentionally tracks state across history, favorites, config,
102134 // and keybindings boundaries for the composite bootstrap assertions.
103135 type fakeBridgeService struct {
@@ -2,6 +2,7 @@ package main
2 2
3 import (3 import (
4 "context"4 "context"
5+ "strings"
5 "testing"6 "testing"
6 "time"7 "time"
7 8
@@ -45,15 +46,16 @@ func TestNewBridgeApp_UsesCurrentConfigForNonConfigRoutes(t *testing.T) {
45 },46 },
46 },47 },
47 }48 }
48- dom := &fakeDOM{}49+ dom := &fakeDOM{mounts: make(chan string, 4)}
49 app := newBridgeApp(dom, "dumb://history", bridge)50 app := newBridgeApp(dom, "dumb://history", bridge)
50 51
51 require.NoError(t, app.Run())52 require.NoError(t, app.Run())
52 require.Eventually(t, func() bool { return bridge.calledHistory }, time.Second, 10*time.Millisecond)53 require.Eventually(t, func() bool { return bridge.calledHistory }, time.Second, 10*time.Millisecond)
53 assert.True(t, bridge.calledConfig)54 assert.True(t, bridge.calledConfig)
54 assert.False(t, bridge.calledKeybindings)55 assert.False(t, bridge.calledKeybindings)
55- assert.Contains(t, dom.html, `class="sv-app sv-dark"`)56+ html := receiveMountContaining(t, dom.mounts, `class="sv-app sv-dark"`, `--sv-background: #111111;`)
56- assert.Contains(t, dom.html, `--sv-background: #111111;`)57+ assert.Contains(t, html, `class="sv-app sv-dark"`)
58+ assert.Contains(t, html, `--sv-background: #111111;`)
57 }59 }
58 60
59 func TestNewBridgeApp_WiresFavoritesService(t *testing.T) {61 func TestNewBridgeApp_WiresFavoritesService(t *testing.T) {
@@ -90,14 +92,44 @@ func TestNewBridgeApp_WiresConfigService(t *testing.T) {
90 }92 }
91 93
92 type fakeDOM struct {94 type fakeDOM struct {
93- html string95+ html string
96+ mounts chan string
94 }97 }
95 98
96 func (f *fakeDOM) Mount(html string) error {99 func (f *fakeDOM) Mount(html string) error {
97 f.html = html100 f.html = html
101+ if f.mounts != nil {
102+ select {
103+ case f.mounts <- html:
104+ default:
105+ }
106+ }
98 return nil107 return nil
99 }108 }
100 109
110+func receiveMountContaining(t *testing.T, mounts <-chan string, values ...string) string {
111+ t.Helper()
112+ deadline := time.After(time.Second)
113+ for {
114+ select {
115+ case html := <-mounts:
116+ matched := true
117+ for _, value := range values {
118+ if !strings.Contains(html, value) {
119+ matched = false
120+ break
121+ }
122+ }
123+ if matched {
124+ return html
125+ }
126+ case <-deadline:
127+ t.Fatalf("timed out waiting for DOM mount containing %q", values)
128+ return ""
129+ }
130+ }
131+}
132+
101 // Handwritten fake intentionally tracks state across history, favorites, config,133 // Handwritten fake intentionally tracks state across history, favorites, config,
102 // and keybindings boundaries for the composite bootstrap assertions.134 // and keybindings boundaries for the composite bootstrap assertions.
103 type fakeBridgeService struct {135 type fakeBridgeService struct {
A internal/application/dto/history.go +14 -0
@@ -0,0 +1,14 @@
1+package dto
2+
3+import "github.com/bnema/dumber/internal/domain/entity"
4+
5+// HistorySearchInput holds search parameters for history search use cases.
6+type HistorySearchInput struct {
7+ Query string
8+ Limit int
9+}
10+
11+// HistorySearchOutput holds search results for history search use cases.
12+type HistorySearchOutput struct {
13+ Matches []entity.HistoryMatch
14+}
@@ -0,0 +1,14 @@
1+package dto
2+
3+import "github.com/bnema/dumber/internal/domain/entity"
4+
5+// HistorySearchInput holds search parameters for history search use cases.
6+type HistorySearchInput struct {
7+ Query string
8+ Limit int
9+}
10+
11+// HistorySearchOutput holds search results for history search use cases.
12+type HistorySearchOutput struct {
13+ Matches []entity.HistoryMatch
14+}
M internal/application/port/homepage.go +1 -12
@@ -8,23 +8,12 @@ import (
88 "github.com/bnema/dumber/internal/domain/entity"
99 )
1010
11-// HistorySearchInput holds search parameters (moved from usecase.SearchInput).
12-type HistorySearchInput struct {
13- Query string
14- Limit int
15-}
16-
17-// HistorySearchOutput holds search results (moved from usecase.SearchOutput).
18-type HistorySearchOutput struct {
19- Matches []entity.HistoryMatch
20-}
21-
2211 // HomepageHistory provides history operations needed by the WebUI homepage handlers.
2312 type HomepageHistory interface {
2413 GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
2514 GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error)
2615 GetRecentWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error)
27- Search(ctx context.Context, input HistorySearchInput) (*HistorySearchOutput, error)
16+ Search(ctx context.Context, input dto.HistorySearchInput) (*dto.HistorySearchOutput, error)
2817 Delete(ctx context.Context, id int64) error
2918 ClearRange(ctx context.Context, rangeID string) error
3019 ClearAll(ctx context.Context) error
@@ -8,23 +8,12 @@ import (
8 "github.com/bnema/dumber/internal/domain/entity"8 "github.com/bnema/dumber/internal/domain/entity"
9 )9 )
10 10
11-// HistorySearchInput holds search parameters (moved from usecase.SearchInput).
12-type HistorySearchInput struct {
13- Query string
14- Limit int
15-}
16-
17-// HistorySearchOutput holds search results (moved from usecase.SearchOutput).
18-type HistorySearchOutput struct {
19- Matches []entity.HistoryMatch
20-}
21-
22 // HomepageHistory provides history operations needed by the WebUI homepage handlers.11 // HomepageHistory provides history operations needed by the WebUI homepage handlers.
23 type HomepageHistory interface {12 type HomepageHistory interface {
24 GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)13 GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
25 GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error)14 GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error)
26 GetRecentWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error)15 GetRecentWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error)
27- Search(ctx context.Context, input HistorySearchInput) (*HistorySearchOutput, error)16+ Search(ctx context.Context, input dto.HistorySearchInput) (*dto.HistorySearchOutput, error)
28 Delete(ctx context.Context, id int64) error17 Delete(ctx context.Context, id int64) error
29 ClearRange(ctx context.Context, rangeID string) error18 ClearRange(ctx context.Context, rangeID string) error
30 ClearAll(ctx context.Context) error19 ClearAll(ctx context.Context) error
M internal/application/port/mocks/mocks.go +12 -12
@@ -7196,26 +7196,26 @@ func (_c *MockHomepageHistory_GetStats_Call) RunAndReturn(run func(ctx context.C
71967196 }
71977197
71987198 // Search provides a mock function for the type MockHomepageHistory
7199-func (_mock *MockHomepageHistory) Search(ctx context.Context, input port.HistorySearchInput) (*port.HistorySearchOutput, error) {
7199+func (_mock *MockHomepageHistory) Search(ctx context.Context, input dto.HistorySearchInput) (*dto.HistorySearchOutput, error) {
72007200 ret := _mock.Called(ctx, input)
72017201
72027202 if len(ret) == 0 {
72037203 panic("no return value specified for Search")
72047204 }
72057205
7206- var r0 *port.HistorySearchOutput
7206+ var r0 *dto.HistorySearchOutput
72077207 var r1 error
7208- if returnFunc, ok := ret.Get(0).(func(context.Context, port.HistorySearchInput) (*port.HistorySearchOutput, error)); ok {
7208+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.HistorySearchInput) (*dto.HistorySearchOutput, error)); ok {
72097209 return returnFunc(ctx, input)
72107210 }
7211- if returnFunc, ok := ret.Get(0).(func(context.Context, port.HistorySearchInput) *port.HistorySearchOutput); ok {
7211+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.HistorySearchInput) *dto.HistorySearchOutput); ok {
72127212 r0 = returnFunc(ctx, input)
72137213 } else {
72147214 if ret.Get(0) != nil {
7215- r0 = ret.Get(0).(*port.HistorySearchOutput)
7215+ r0 = ret.Get(0).(*dto.HistorySearchOutput)
72167216 }
72177217 }
7218- if returnFunc, ok := ret.Get(1).(func(context.Context, port.HistorySearchInput) error); ok {
7218+ if returnFunc, ok := ret.Get(1).(func(context.Context, dto.HistorySearchInput) error); ok {
72197219 r1 = returnFunc(ctx, input)
72207220 } else {
72217221 r1 = ret.Error(1)
@@ -7230,20 +7230,20 @@ type MockHomepageHistory_Search_Call struct {
72307230
72317231 // Search is a helper method to define mock.On call
72327232 // - ctx context.Context
7233-// - input port.HistorySearchInput
7233+// - input dto.HistorySearchInput
72347234 func (_e *MockHomepageHistory_Expecter) Search(ctx interface{}, input interface{}) *MockHomepageHistory_Search_Call {
72357235 return &MockHomepageHistory_Search_Call{Call: _e.mock.On("Search", ctx, input)}
72367236 }
72377237
7238-func (_c *MockHomepageHistory_Search_Call) Run(run func(ctx context.Context, input port.HistorySearchInput)) *MockHomepageHistory_Search_Call {
7238+func (_c *MockHomepageHistory_Search_Call) Run(run func(ctx context.Context, input dto.HistorySearchInput)) *MockHomepageHistory_Search_Call {
72397239 _c.Call.Run(func(args mock.Arguments) {
72407240 var arg0 context.Context
72417241 if args[0] != nil {
72427242 arg0 = args[0].(context.Context)
72437243 }
7244- var arg1 port.HistorySearchInput
7244+ var arg1 dto.HistorySearchInput
72457245 if args[1] != nil {
7246- arg1 = args[1].(port.HistorySearchInput)
7246+ arg1 = args[1].(dto.HistorySearchInput)
72477247 }
72487248 run(
72497249 arg0,
@@ -7253,12 +7253,12 @@ func (_c *MockHomepageHistory_Search_Call) Run(run func(ctx context.Context, inp
72537253 return _c
72547254 }
72557255
7256-func (_c *MockHomepageHistory_Search_Call) Return(historySearchOutput *port.HistorySearchOutput, err error) *MockHomepageHistory_Search_Call {
7256+func (_c *MockHomepageHistory_Search_Call) Return(historySearchOutput *dto.HistorySearchOutput, err error) *MockHomepageHistory_Search_Call {
72577257 _c.Call.Return(historySearchOutput, err)
72587258 return _c
72597259 }
72607260
7261-func (_c *MockHomepageHistory_Search_Call) RunAndReturn(run func(ctx context.Context, input port.HistorySearchInput) (*port.HistorySearchOutput, error)) *MockHomepageHistory_Search_Call {
7261+func (_c *MockHomepageHistory_Search_Call) RunAndReturn(run func(ctx context.Context, input dto.HistorySearchInput) (*dto.HistorySearchOutput, error)) *MockHomepageHistory_Search_Call {
72627262 _c.Call.Return(run)
72637263 return _c
72647264 }
@@ -7196,26 +7196,26 @@ func (_c *MockHomepageHistory_GetStats_Call) RunAndReturn(run func(ctx context.C
7196 }7196 }
7197 7197
7198 // Search provides a mock function for the type MockHomepageHistory7198 // Search provides a mock function for the type MockHomepageHistory
7199-func (_mock *MockHomepageHistory) Search(ctx context.Context, input port.HistorySearchInput) (*port.HistorySearchOutput, error) {7199+func (_mock *MockHomepageHistory) Search(ctx context.Context, input dto.HistorySearchInput) (*dto.HistorySearchOutput, error) {
7200 ret := _mock.Called(ctx, input)7200 ret := _mock.Called(ctx, input)
7201 7201
7202 if len(ret) == 0 {7202 if len(ret) == 0 {
7203 panic("no return value specified for Search")7203 panic("no return value specified for Search")
7204 }7204 }
7205 7205
7206- var r0 *port.HistorySearchOutput7206+ var r0 *dto.HistorySearchOutput
7207 var r1 error7207 var r1 error
7208- if returnFunc, ok := ret.Get(0).(func(context.Context, port.HistorySearchInput) (*port.HistorySearchOutput, error)); ok {7208+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.HistorySearchInput) (*dto.HistorySearchOutput, error)); ok {
7209 return returnFunc(ctx, input)7209 return returnFunc(ctx, input)
7210 }7210 }
7211- if returnFunc, ok := ret.Get(0).(func(context.Context, port.HistorySearchInput) *port.HistorySearchOutput); ok {7211+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.HistorySearchInput) *dto.HistorySearchOutput); ok {
7212 r0 = returnFunc(ctx, input)7212 r0 = returnFunc(ctx, input)
7213 } else {7213 } else {
7214 if ret.Get(0) != nil {7214 if ret.Get(0) != nil {
7215- r0 = ret.Get(0).(*port.HistorySearchOutput)7215+ r0 = ret.Get(0).(*dto.HistorySearchOutput)
7216 }7216 }
7217 }7217 }
7218- if returnFunc, ok := ret.Get(1).(func(context.Context, port.HistorySearchInput) error); ok {7218+ if returnFunc, ok := ret.Get(1).(func(context.Context, dto.HistorySearchInput) error); ok {
7219 r1 = returnFunc(ctx, input)7219 r1 = returnFunc(ctx, input)
7220 } else {7220 } else {
7221 r1 = ret.Error(1)7221 r1 = ret.Error(1)
@@ -7230,20 +7230,20 @@ type MockHomepageHistory_Search_Call struct {
7230 7230
7231 // Search is a helper method to define mock.On call7231 // Search is a helper method to define mock.On call
7232 // - ctx context.Context7232 // - ctx context.Context
7233-// - input port.HistorySearchInput7233+// - input dto.HistorySearchInput
7234 func (_e *MockHomepageHistory_Expecter) Search(ctx interface{}, input interface{}) *MockHomepageHistory_Search_Call {7234 func (_e *MockHomepageHistory_Expecter) Search(ctx interface{}, input interface{}) *MockHomepageHistory_Search_Call {
7235 return &MockHomepageHistory_Search_Call{Call: _e.mock.On("Search", ctx, input)}7235 return &MockHomepageHistory_Search_Call{Call: _e.mock.On("Search", ctx, input)}
7236 }7236 }
7237 7237
7238-func (_c *MockHomepageHistory_Search_Call) Run(run func(ctx context.Context, input port.HistorySearchInput)) *MockHomepageHistory_Search_Call {7238+func (_c *MockHomepageHistory_Search_Call) Run(run func(ctx context.Context, input dto.HistorySearchInput)) *MockHomepageHistory_Search_Call {
7239 _c.Call.Run(func(args mock.Arguments) {7239 _c.Call.Run(func(args mock.Arguments) {
7240 var arg0 context.Context7240 var arg0 context.Context
7241 if args[0] != nil {7241 if args[0] != nil {
7242 arg0 = args[0].(context.Context)7242 arg0 = args[0].(context.Context)
7243 }7243 }
7244- var arg1 port.HistorySearchInput7244+ var arg1 dto.HistorySearchInput
7245 if args[1] != nil {7245 if args[1] != nil {
7246- arg1 = args[1].(port.HistorySearchInput)7246+ arg1 = args[1].(dto.HistorySearchInput)
7247 }7247 }
7248 run(7248 run(
7249 arg0,7249 arg0,
@@ -7253,12 +7253,12 @@ func (_c *MockHomepageHistory_Search_Call) Run(run func(ctx context.Context, inp
7253 return _c7253 return _c
7254 }7254 }
7255 7255
7256-func (_c *MockHomepageHistory_Search_Call) Return(historySearchOutput *port.HistorySearchOutput, err error) *MockHomepageHistory_Search_Call {7256+func (_c *MockHomepageHistory_Search_Call) Return(historySearchOutput *dto.HistorySearchOutput, err error) *MockHomepageHistory_Search_Call {
7257 _c.Call.Return(historySearchOutput, err)7257 _c.Call.Return(historySearchOutput, err)
7258 return _c7258 return _c
7259 }7259 }
7260 7260
7261-func (_c *MockHomepageHistory_Search_Call) RunAndReturn(run func(ctx context.Context, input port.HistorySearchInput) (*port.HistorySearchOutput, error)) *MockHomepageHistory_Search_Call {7261+func (_c *MockHomepageHistory_Search_Call) RunAndReturn(run func(ctx context.Context, input dto.HistorySearchInput) (*dto.HistorySearchOutput, error)) *MockHomepageHistory_Search_Call {
7262 _c.Call.Return(run)7262 _c.Call.Return(run)
7263 return _c7263 return _c
7264 }7264 }
M internal/application/usecase/manage_favorites.go +20 -16
@@ -102,6 +102,11 @@ func (uc *ManageFavoritesUseCase) addNewFavorite(ctx context.Context, input AddF
102102
103103 // AddFavorite creates a favorite from the UI-facing application port.
104104 func (uc *ManageFavoritesUseCase) AddFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
105+ for _, tagID := range input.Tags {
106+ if tagID <= 0 {
107+ return nil, fmt.Errorf("favorite tag id must be positive, got %d", tagID)
108+ }
109+ }
105110 favoriteURL, err := normalizeFavoriteURL(input.URL)
106111 if err != nil {
107112 return nil, err
@@ -111,12 +116,7 @@ func (uc *ManageFavoritesUseCase) AddFavorite(ctx context.Context, input dto.Fav
111116 } else if existing != nil {
112117 return existing, nil
113118 }
114- tags := make([]entity.TagID, 0, len(input.Tags))
115- for _, tagID := range input.Tags {
116- if tagID > 0 {
117- tags = append(tags, tagID)
118- }
119- }
119+ tags := append([]entity.TagID(nil), input.Tags...)
120120 title := strings.TrimSpace(input.Title)
121121 if title == "" {
122122 title = favoriteURL
@@ -130,7 +130,11 @@ func (uc *ManageFavoritesUseCase) AddFavorite(ctx context.Context, input dto.Fav
130130 })
131131 }
132132
133-func (uc *ManageFavoritesUseCase) findFavoriteByCanonicalOrRawURL(ctx context.Context, canonicalURL, rawURL string) (*entity.Favorite, error) {
133+func (uc *ManageFavoritesUseCase) findFavoriteByCanonicalOrRawURL(
134+ ctx context.Context,
135+ canonicalURL string,
136+ rawURL string,
137+) (*entity.Favorite, error) {
134138 existing, err := uc.favoriteRepo.FindByURL(ctx, canonicalURL)
135139 if err != nil {
136140 return nil, fmt.Errorf("failed to check existing favorite: %w", err)
@@ -229,16 +233,16 @@ func (uc *ManageFavoritesUseCase) Remove(ctx context.Context, id entity.Favorite
229233 }
230234
231235 // RemoveByURL deletes a favorite by its URL.
232-func (uc *ManageFavoritesUseCase) RemoveByURL(ctx context.Context, url string) error {
236+func (uc *ManageFavoritesUseCase) RemoveByURL(ctx context.Context, favoriteURL string) error {
233237 log := logging.FromContext(ctx)
234- log.Debug().Str("url", url).Msg("removing favorite by URL")
238+ log.Debug().Str("url", favoriteURL).Msg("removing favorite by URL")
235239
236- fav, err := uc.favoriteRepo.FindByURL(ctx, url)
240+ fav, err := uc.favoriteRepo.FindByURL(ctx, favoriteURL)
237241 if err != nil {
238242 return fmt.Errorf("failed to find favorite: %w", err)
239243 }
240244 if fav == nil {
241- log.Debug().Str("url", url).Msg("favorite not found")
245+ log.Debug().Str("url", favoriteURL).Msg("favorite not found")
242246 return nil
243247 }
244248
@@ -308,11 +312,11 @@ func (uc *ManageFavoritesUseCase) GetByShortcut(ctx context.Context, key int) (*
308312 }
309313
310314 // GetByURL finds a favorite by its URL.
311-func (uc *ManageFavoritesUseCase) GetByURL(ctx context.Context, url string) (*entity.Favorite, error) {
315+func (uc *ManageFavoritesUseCase) GetByURL(ctx context.Context, favoriteURL string) (*entity.Favorite, error) {
312316 log := logging.FromContext(ctx)
313- log.Debug().Str("url", url).Msg("getting favorite by URL")
317+ log.Debug().Str("url", favoriteURL).Msg("getting favorite by URL")
314318
315- return uc.favoriteRepo.FindByURL(ctx, url)
319+ return uc.favoriteRepo.FindByURL(ctx, favoriteURL)
316320 }
317321
318322 // GetAll retrieves all favorites.
@@ -389,8 +393,8 @@ func (uc *ManageFavoritesUseCase) FilterForOmnibox(ctx context.Context, query st
389393 }
390394
391395 // IsFavorite checks if a URL is favorited.
392-func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, url string) (bool, error) {
393- fav, err := uc.favoriteRepo.FindByURL(ctx, url)
396+func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, favoriteURL string) (bool, error) {
397+ fav, err := uc.favoriteRepo.FindByURL(ctx, favoriteURL)
394398 if err != nil {
395399 return false, fmt.Errorf("failed to check favorite: %w", err)
396400 }
@@ -102,6 +102,11 @@ func (uc *ManageFavoritesUseCase) addNewFavorite(ctx context.Context, input AddF
102 102
103 // AddFavorite creates a favorite from the UI-facing application port.103 // AddFavorite creates a favorite from the UI-facing application port.
104 func (uc *ManageFavoritesUseCase) AddFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {104 func (uc *ManageFavoritesUseCase) AddFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
105+ for _, tagID := range input.Tags {
106+ if tagID <= 0 {
107+ return nil, fmt.Errorf("favorite tag id must be positive, got %d", tagID)
108+ }
109+ }
105 favoriteURL, err := normalizeFavoriteURL(input.URL)110 favoriteURL, err := normalizeFavoriteURL(input.URL)
106 if err != nil {111 if err != nil {
107 return nil, err112 return nil, err
@@ -111,12 +116,7 @@ func (uc *ManageFavoritesUseCase) AddFavorite(ctx context.Context, input dto.Fav
111 } else if existing != nil {116 } else if existing != nil {
112 return existing, nil117 return existing, nil
113 }118 }
114- tags := make([]entity.TagID, 0, len(input.Tags))119+ tags := append([]entity.TagID(nil), input.Tags...)
115- for _, tagID := range input.Tags {
116- if tagID > 0 {
117- tags = append(tags, tagID)
118- }
119- }
120 title := strings.TrimSpace(input.Title)120 title := strings.TrimSpace(input.Title)
121 if title == "" {121 if title == "" {
122 title = favoriteURL122 title = favoriteURL
@@ -130,7 +130,11 @@ func (uc *ManageFavoritesUseCase) AddFavorite(ctx context.Context, input dto.Fav
130 })130 })
131 }131 }
132 132
133-func (uc *ManageFavoritesUseCase) findFavoriteByCanonicalOrRawURL(ctx context.Context, canonicalURL, rawURL string) (*entity.Favorite, error) {133+func (uc *ManageFavoritesUseCase) findFavoriteByCanonicalOrRawURL(
134+ ctx context.Context,
135+ canonicalURL string,
136+ rawURL string,
137+) (*entity.Favorite, error) {
134 existing, err := uc.favoriteRepo.FindByURL(ctx, canonicalURL)138 existing, err := uc.favoriteRepo.FindByURL(ctx, canonicalURL)
135 if err != nil {139 if err != nil {
136 return nil, fmt.Errorf("failed to check existing favorite: %w", err)140 return nil, fmt.Errorf("failed to check existing favorite: %w", err)
@@ -229,16 +233,16 @@ func (uc *ManageFavoritesUseCase) Remove(ctx context.Context, id entity.Favorite
229 }233 }
230 234
231 // RemoveByURL deletes a favorite by its URL.235 // RemoveByURL deletes a favorite by its URL.
232-func (uc *ManageFavoritesUseCase) RemoveByURL(ctx context.Context, url string) error {236+func (uc *ManageFavoritesUseCase) RemoveByURL(ctx context.Context, favoriteURL string) error {
233 log := logging.FromContext(ctx)237 log := logging.FromContext(ctx)
234- log.Debug().Str("url", url).Msg("removing favorite by URL")238+ log.Debug().Str("url", favoriteURL).Msg("removing favorite by URL")
235 239
236- fav, err := uc.favoriteRepo.FindByURL(ctx, url)240+ fav, err := uc.favoriteRepo.FindByURL(ctx, favoriteURL)
237 if err != nil {241 if err != nil {
238 return fmt.Errorf("failed to find favorite: %w", err)242 return fmt.Errorf("failed to find favorite: %w", err)
239 }243 }
240 if fav == nil {244 if fav == nil {
241- log.Debug().Str("url", url).Msg("favorite not found")245+ log.Debug().Str("url", favoriteURL).Msg("favorite not found")
242 return nil246 return nil
243 }247 }
244 248
@@ -308,11 +312,11 @@ func (uc *ManageFavoritesUseCase) GetByShortcut(ctx context.Context, key int) (*
308 }312 }
309 313
310 // GetByURL finds a favorite by its URL.314 // GetByURL finds a favorite by its URL.
311-func (uc *ManageFavoritesUseCase) GetByURL(ctx context.Context, url string) (*entity.Favorite, error) {315+func (uc *ManageFavoritesUseCase) GetByURL(ctx context.Context, favoriteURL string) (*entity.Favorite, error) {
312 log := logging.FromContext(ctx)316 log := logging.FromContext(ctx)
313- log.Debug().Str("url", url).Msg("getting favorite by URL")317+ log.Debug().Str("url", favoriteURL).Msg("getting favorite by URL")
314 318
315- return uc.favoriteRepo.FindByURL(ctx, url)319+ return uc.favoriteRepo.FindByURL(ctx, favoriteURL)
316 }320 }
317 321
318 // GetAll retrieves all favorites.322 // GetAll retrieves all favorites.
@@ -389,8 +393,8 @@ func (uc *ManageFavoritesUseCase) FilterForOmnibox(ctx context.Context, query st
389 }393 }
390 394
391 // IsFavorite checks if a URL is favorited.395 // IsFavorite checks if a URL is favorited.
392-func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, url string) (bool, error) {396+func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, favoriteURL string) (bool, error) {
393- fav, err := uc.favoriteRepo.FindByURL(ctx, url)397+ fav, err := uc.favoriteRepo.FindByURL(ctx, favoriteURL)
394 if err != nil {398 if err != nil {
395 return false, fmt.Errorf("failed to check favorite: %w", err)399 return false, fmt.Errorf("failed to check favorite: %w", err)
396 }400 }
M internal/application/usecase/manage_favorites_test.go +13 -0
@@ -389,6 +389,19 @@ func TestManageFavoritesUseCase_AddFavoriteNormalizesURL(t *testing.T) {
389389 assert.Equal(t, "Example", fav.Title)
390390 }
391391
392+func TestManageFavoritesUseCase_AddFavoriteRejectsInvalidTagIDs(t *testing.T) {
393+ ctx := testContext()
394+
395+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
396+ folderRepo := repomocks.NewMockFolderRepository(t)
397+ tagRepo := repomocks.NewMockTagRepository(t)
398+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
399+
400+ _, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: "https://example.com", Tags: []entity.TagID{1, 0}})
401+ require.Error(t, err)
402+ assert.Contains(t, err.Error(), "favorite tag id must be positive")
403+}
404+
392405 func TestManageFavoritesUseCase_AddFavoriteRejectsUnsafeOrMalformedURL(t *testing.T) {
393406 ctx := testContext()
394407
@@ -389,6 +389,19 @@ func TestManageFavoritesUseCase_AddFavoriteNormalizesURL(t *testing.T) {
389 assert.Equal(t, "Example", fav.Title)389 assert.Equal(t, "Example", fav.Title)
390 }390 }
391 391
392+func TestManageFavoritesUseCase_AddFavoriteRejectsInvalidTagIDs(t *testing.T) {
393+ ctx := testContext()
394+
395+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
396+ folderRepo := repomocks.NewMockFolderRepository(t)
397+ tagRepo := repomocks.NewMockTagRepository(t)
398+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
399+
400+ _, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: "https://example.com", Tags: []entity.TagID{1, 0}})
401+ require.Error(t, err)
402+ assert.Contains(t, err.Error(), "favorite tag id must be positive")
403+}
404+
392 func TestManageFavoritesUseCase_AddFavoriteRejectsUnsafeOrMalformedURL(t *testing.T) {405 func TestManageFavoritesUseCase_AddFavoriteRejectsUnsafeOrMalformedURL(t *testing.T) {
393 ctx := testContext()406 ctx := testContext()
394 407
M internal/application/usecase/save_webui_config.go +2 -2
@@ -85,11 +85,11 @@ func validateWebUIConfig(cfg dto.WebUIConfig) error {
8585 }
8686
8787 switch cfg.Performance.Profile {
88- case "", "default", "lite", "max", "custom":
88+ case "", "default", "lite", "balanced", "max", "custom":
8989 // ok
9090 default:
9191 errs = append(errs, fmt.Sprintf(
92- "performance.profile must be one of: default, lite, max, custom (got: %s)",
92+ "performance.profile must be one of: default, lite, balanced, max, custom (got: %s)",
9393 cfg.Performance.Profile,
9494 ))
9595 }
@@ -85,11 +85,11 @@ func validateWebUIConfig(cfg dto.WebUIConfig) error {
85 }85 }
86 86
87 switch cfg.Performance.Profile {87 switch cfg.Performance.Profile {
88- case "", "default", "lite", "max", "custom":88+ case "", "default", "lite", "balanced", "max", "custom":
89 // ok89 // ok
90 default:90 default:
91 errs = append(errs, fmt.Sprintf(91 errs = append(errs, fmt.Sprintf(
92- "performance.profile must be one of: default, lite, max, custom (got: %s)",92+ "performance.profile must be one of: default, lite, balanced, max, custom (got: %s)",
93 cfg.Performance.Profile,93 cfg.Performance.Profile,
94 ))94 ))
95 }95 }
M internal/application/usecase/search_history.go +5 -4
@@ -6,6 +6,7 @@ import (
66 "strings"
77 "time"
88
9+ "github.com/bnema/dumber/internal/application/dto"
910 "github.com/bnema/dumber/internal/application/port"
1011 "github.com/bnema/dumber/internal/domain/entity"
1112 historydomain "github.com/bnema/dumber/internal/domain/history"
@@ -31,11 +32,11 @@ func NewSearchHistoryUseCase(historyRepo repository.HistoryRepository) *SearchHi
3132 }
3233 }
3334
34-// SearchInput is an alias for port.HistorySearchInput.
35-type SearchInput = port.HistorySearchInput
35+// SearchInput is an alias for dto.HistorySearchInput.
36+type SearchInput = dto.HistorySearchInput
3637
37-// SearchOutput is an alias for port.HistorySearchOutput.
38-type SearchOutput = port.HistorySearchOutput
38+// SearchOutput is an alias for dto.HistorySearchOutput.
39+type SearchOutput = dto.HistorySearchOutput
3940
4041 // Search performs a full-text search on history entries using SQLite FTS5.
4142 // Returns only entries that actually match the query terms.
@@ -6,6 +6,7 @@ import (
6 "strings"6 "strings"
7 "time"7 "time"
8 8
9+ "github.com/bnema/dumber/internal/application/dto"
9 "github.com/bnema/dumber/internal/application/port"10 "github.com/bnema/dumber/internal/application/port"
10 "github.com/bnema/dumber/internal/domain/entity"11 "github.com/bnema/dumber/internal/domain/entity"
11 historydomain "github.com/bnema/dumber/internal/domain/history"12 historydomain "github.com/bnema/dumber/internal/domain/history"
@@ -31,11 +32,11 @@ func NewSearchHistoryUseCase(historyRepo repository.HistoryRepository) *SearchHi
31 }32 }
32 }33 }
33 34
34-// SearchInput is an alias for port.HistorySearchInput.35+// SearchInput is an alias for dto.HistorySearchInput.
35-type SearchInput = port.HistorySearchInput36+type SearchInput = dto.HistorySearchInput
36 37
37-// SearchOutput is an alias for port.HistorySearchOutput.38+// SearchOutput is an alias for dto.HistorySearchOutput.
38-type SearchOutput = port.HistorySearchOutput39+type SearchOutput = dto.HistorySearchOutput
39 40
40 // Search performs a full-text search on history entries using SQLite FTS5.41 // Search performs a full-text search on history entries using SQLite FTS5.
41 // Returns only entries that actually match the query terms.42 // Returns only entries that actually match the query terms.
M internal/infrastructure/cef/scheme_handler.go +126 -29
@@ -179,9 +179,14 @@ func (h *dumbSchemeHandler) Create(browser purecef.Browser, _ purecef.Frame, _ s
179179 }
180180
181181 // handleAPI routes API requests to the message router or built-in handlers.
182-func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path string, request purecef.Request) purecef.ResourceHandler {
182+func (h *dumbSchemeHandler) handleAPI(
183+ browser purecef.Browser,
184+ method string,
185+ requestPath string,
186+ request purecef.Request,
187+) purecef.ResourceHandler {
183188 if strings.EqualFold(method, http.MethodOptions) {
184- if path == "/api/config" || path == "/api/config/default" {
189+ if requestPath == "/api/config" || requestPath == "/api/config/default" {
185190 if denied := h.rejectUntrustedConfigRequester(request); denied != nil {
186191 return denied
187192 }
@@ -190,12 +195,12 @@ func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path stri
190195 return h.newAPIRawResourceHandler(http.StatusNoContent, "text/plain; charset=utf-8", nil)
191196 }
192197 if isAPIGetMethod(method) {
193- if handler, ok := h.handleAPIGet(path, request); ok {
198+ if handler, ok := h.handleAPIGet(requestPath, request); ok {
194199 return handler
195200 }
196201 }
197202 if isAPIPostMethod(method) {
198- if handler, ok := h.handleAPIPost(browser, path, request); ok {
203+ if handler, ok := h.handleAPIPost(browser, requestPath, request); ok {
199204 return handler
200205 }
201206 }
@@ -210,8 +215,8 @@ func isAPIPostMethod(method string) bool {
210215 return strings.EqualFold(method, http.MethodPost)
211216 }
212217
213-func (h *dumbSchemeHandler) handleAPIGet(path string, request purecef.Request) (purecef.ResourceHandler, bool) {
214- switch path {
218+func (h *dumbSchemeHandler) handleAPIGet(requestPath string, request purecef.Request) (purecef.ResourceHandler, bool) {
219+ switch requestPath {
215220 case "/api/config":
216221 if denied := h.rejectUntrustedConfigRequester(request); denied != nil {
217222 return denied, true
@@ -231,8 +236,12 @@ func (h *dumbSchemeHandler) handleAPIGet(path string, request purecef.Request) (
231236 }
232237 }
233238
234-func (h *dumbSchemeHandler) handleAPIPost(browser purecef.Browser, path string, request purecef.Request) (purecef.ResourceHandler, bool) {
235- switch path {
239+func (h *dumbSchemeHandler) handleAPIPost(
240+ browser purecef.Browser,
241+ requestPath string,
242+ request purecef.Request,
243+) (purecef.ResourceHandler, bool) {
244+ switch requestPath {
236245 case "/api/message":
237246 return h.handleMessageAPI(request), true
238247 case "/api/clipboard-set":
@@ -665,7 +674,7 @@ func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler {
665674 return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found")
666675 }
667676
668- data, headers, err := readAssetWithEncoding(h.assets, fullPath, relPath)
677+ data, err := readAssetWithEncoding(h.assets, fullPath, relPath)
669678 if err != nil {
670679 h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")
671680 return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found")
@@ -678,7 +687,7 @@ func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler {
678687 Int("size", len(data)).
679688 Msg("serving asset")
680689
681- return newStaticResourceHandler(http.StatusOK, contentType, data, headers)
690+ return newStaticResourceHandler(http.StatusOK, contentType, data, nil)
682691 }
683692
684693 func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath string, ok bool) {
@@ -704,21 +713,21 @@ func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath
704713 return fullPath, cleanRelPath, true
705714 }
706715
707-func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, map[string]string, error) {
716+func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, error) {
708717 if strings.HasSuffix(relPath, ".wasm") {
709718 if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil {
710719 data, err := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(compressed)), maxSystemviewsWASMBytes+1))
711720 if err != nil {
712- return nil, nil, err
721+ return nil, err
713722 }
714723 if len(data) > maxSystemviewsWASMBytes {
715- return nil, nil, fmt.Errorf("decompressed asset %s exceeds %d bytes", fullPath, maxSystemviewsWASMBytes)
724+ return nil, fmt.Errorf("decompressed asset %s exceeds %d bytes", fullPath, maxSystemviewsWASMBytes)
716725 }
717- return data, nil, nil
726+ return data, nil
718727 }
719728 }
720729 data, err := fs.ReadFile(assets, fullPath)
721- return data, nil, err
730+ return data, err
722731 }
723732
724733 // resolveAssetPath maps either a dumb:// URL or the actual internal HTTPS URL
@@ -741,15 +750,15 @@ func resolveConceptualAssetPath(u *url.URL) (assetDir, relPath string, ok bool)
741750 }
742751
743752 if root, ok := pageRootFiles[u.Host]; ok {
744- path := strings.TrimPrefix(u.Path, "/")
745- if path == "" {
753+ assetPath := strings.TrimPrefix(u.Path, "/")
754+ if assetPath == "" {
746755 return assetDirForPageHost(u.Host), root, true
747756 }
748757 // Don't serve API paths as assets.
749- if strings.HasPrefix(path, "api/") {
758+ if strings.HasPrefix(assetPath, "api/") {
750759 return "", "", false
751760 }
752- return assetDirForPageHost(u.Host), path, true
761+ return assetDirForPageHost(u.Host), assetPath, true
753762 }
754763
755764 // Handle opaque URLs like dumb:history.
@@ -764,12 +773,12 @@ func resolveActualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
764773 return "", "", false
765774 }
766775
767- path := strings.Trim(u.Path, "/")
768- if path == "" {
776+ assetPath := strings.Trim(u.Path, "/")
777+ if assetPath == "" {
769778 return "", "", false
770779 }
771780
772- parts := strings.SplitN(path, "/", 2)
781+ parts := strings.SplitN(assetPath, "/", 2)
773782 page := parts[0]
774783 if root, ok := pageRootFiles[page]; ok {
775784 if len(parts) == 1 {
@@ -781,12 +790,12 @@ func resolveActualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
781790 return assetDirForPageHost(page), parts[1], true
782791 }
783792
784- if strings.HasPrefix(path, "api/") {
793+ if strings.HasPrefix(assetPath, "api/") {
785794 return "", "", false
786795 }
787796
788- if !strings.Contains(path, "/") && strings.Contains(path, ".") {
789- return systemviewsAssetDir, path, true
797+ if !strings.Contains(assetPath, "/") && strings.Contains(assetPath, ".") {
798+ return systemviewsAssetDir, assetPath, true
790799 }
791800
792801 return "", "", false
@@ -929,6 +938,79 @@ func newFaviconResourceHandler(service port.FaviconService, domain string, size
929938 })
930939 }
931940
941+const maxFaviconMemoryCacheEntries = 256
942+
943+var systemviewFaviconCache = newFaviconMemoryCache(maxFaviconMemoryCacheEntries)
944+
945+type faviconCacheEntry struct {
946+ statusCode int
947+ contentType string
948+ data []byte
949+}
950+
951+type faviconMemoryCache struct {
952+ mu sync.Mutex
953+ max int
954+ entries map[string]faviconCacheEntry
955+ order []string
956+}
957+
958+func newFaviconMemoryCache(maxEntries int) *faviconMemoryCache {
959+ return &faviconMemoryCache{
960+ max: maxEntries,
961+ entries: make(map[string]faviconCacheEntry),
962+ order: make([]string, 0, maxEntries),
963+ }
964+}
965+
966+func faviconCacheKey(domain string, size int) string {
967+ return strings.ToLower(strings.TrimSpace(domain)) + "\x00" + strconv.Itoa(size)
968+}
969+
970+func (c *faviconMemoryCache) get(key string) (faviconCacheEntry, bool) {
971+ if c == nil || key == "" {
972+ return faviconCacheEntry{}, false
973+ }
974+ c.mu.Lock()
975+ defer c.mu.Unlock()
976+ entry, ok := c.entries[key]
977+ if !ok {
978+ return faviconCacheEntry{}, false
979+ }
980+ c.moveToBackLocked(key)
981+ return entry, true
982+}
983+
984+func (c *faviconMemoryCache) put(key string, entry faviconCacheEntry) {
985+ if c == nil || key == "" || c.max <= 0 {
986+ return
987+ }
988+ c.mu.Lock()
989+ defer c.mu.Unlock()
990+ if _, ok := c.entries[key]; !ok {
991+ c.order = append(c.order, key)
992+ } else {
993+ c.moveToBackLocked(key)
994+ }
995+ c.entries[key] = entry
996+ for len(c.order) > c.max {
997+ oldest := c.order[0]
998+ c.order = c.order[1:]
999+ delete(c.entries, oldest)
1000+ }
1001+}
1002+
1003+func (c *faviconMemoryCache) moveToBackLocked(key string) {
1004+ for i, candidate := range c.order {
1005+ if candidate != key {
1006+ continue
1007+ }
1008+ copy(c.order[i:], c.order[i+1:])
1009+ c.order[len(c.order)-1] = key
1010+ return
1011+ }
1012+}
1013+
9321014 type faviconResourceHandler struct {
9331015 service port.FaviconService
9341016 domain string
@@ -947,20 +1029,35 @@ func (rh *faviconResourceHandler) load() {
9471029 rh.statusCode = http.StatusNotFound
9481030 rh.contentType = "application/json"
9491031 rh.data = []byte(`{"error":"favicon not cached"}`)
950- if rh.service == nil || !rh.service.HasPNGSizedOnDisk(rh.domain, rh.size) {
1032+ if rh.service == nil {
1033+ return
1034+ }
1035+ cacheKey := faviconCacheKey(rh.domain, rh.size)
1036+ if entry, ok := systemviewFaviconCache.get(cacheKey); ok {
1037+ rh.statusCode = entry.statusCode
1038+ rh.contentType = entry.contentType
1039+ rh.data = entry.data
1040+ return
1041+ }
1042+ if !rh.service.HasPNGSizedOnDisk(rh.domain, rh.size) {
9511043 return
9521044 }
953- path := rh.service.DiskPathPNGSized(rh.domain, rh.size)
954- if path == "" {
1045+ diskPath := rh.service.DiskPathPNGSized(rh.domain, rh.size)
1046+ if diskPath == "" {
9551047 return
9561048 }
957- data, err := os.ReadFile(path)
1049+ data, err := os.ReadFile(diskPath)
9581050 if err != nil || len(data) == 0 {
9591051 return
9601052 }
9611053 rh.statusCode = http.StatusOK
9621054 rh.contentType = "image/png"
9631055 rh.data = data
1056+ systemviewFaviconCache.put(cacheKey, faviconCacheEntry{
1057+ statusCode: rh.statusCode,
1058+ contentType: rh.contentType,
1059+ data: data,
1060+ })
9641061 }
9651062
9661063 func (rh *faviconResourceHandler) Open(_ purecef.Request, handleRequest *int32, callback purecef.Callback) int32 {
@@ -179,9 +179,14 @@ func (h *dumbSchemeHandler) Create(browser purecef.Browser, _ purecef.Frame, _ s
179 }179 }
180 180
181 // handleAPI routes API requests to the message router or built-in handlers.181 // handleAPI routes API requests to the message router or built-in handlers.
182-func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path string, request purecef.Request) purecef.ResourceHandler {182+func (h *dumbSchemeHandler) handleAPI(
183+ browser purecef.Browser,
184+ method string,
185+ requestPath string,
186+ request purecef.Request,
187+) purecef.ResourceHandler {
183 if strings.EqualFold(method, http.MethodOptions) {188 if strings.EqualFold(method, http.MethodOptions) {
184- if path == "/api/config" || path == "/api/config/default" {189+ if requestPath == "/api/config" || requestPath == "/api/config/default" {
185 if denied := h.rejectUntrustedConfigRequester(request); denied != nil {190 if denied := h.rejectUntrustedConfigRequester(request); denied != nil {
186 return denied191 return denied
187 }192 }
@@ -190,12 +195,12 @@ func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path stri
190 return h.newAPIRawResourceHandler(http.StatusNoContent, "text/plain; charset=utf-8", nil)195 return h.newAPIRawResourceHandler(http.StatusNoContent, "text/plain; charset=utf-8", nil)
191 }196 }
192 if isAPIGetMethod(method) {197 if isAPIGetMethod(method) {
193- if handler, ok := h.handleAPIGet(path, request); ok {198+ if handler, ok := h.handleAPIGet(requestPath, request); ok {
194 return handler199 return handler
195 }200 }
196 }201 }
197 if isAPIPostMethod(method) {202 if isAPIPostMethod(method) {
198- if handler, ok := h.handleAPIPost(browser, path, request); ok {203+ if handler, ok := h.handleAPIPost(browser, requestPath, request); ok {
199 return handler204 return handler
200 }205 }
201 }206 }
@@ -210,8 +215,8 @@ func isAPIPostMethod(method string) bool {
210 return strings.EqualFold(method, http.MethodPost)215 return strings.EqualFold(method, http.MethodPost)
211 }216 }
212 217
213-func (h *dumbSchemeHandler) handleAPIGet(path string, request purecef.Request) (purecef.ResourceHandler, bool) {218+func (h *dumbSchemeHandler) handleAPIGet(requestPath string, request purecef.Request) (purecef.ResourceHandler, bool) {
214- switch path {219+ switch requestPath {
215 case "/api/config":220 case "/api/config":
216 if denied := h.rejectUntrustedConfigRequester(request); denied != nil {221 if denied := h.rejectUntrustedConfigRequester(request); denied != nil {
217 return denied, true222 return denied, true
@@ -231,8 +236,12 @@ func (h *dumbSchemeHandler) handleAPIGet(path string, request purecef.Request) (
231 }236 }
232 }237 }
233 238
234-func (h *dumbSchemeHandler) handleAPIPost(browser purecef.Browser, path string, request purecef.Request) (purecef.ResourceHandler, bool) {239+func (h *dumbSchemeHandler) handleAPIPost(
235- switch path {240+ browser purecef.Browser,
241+ requestPath string,
242+ request purecef.Request,
243+) (purecef.ResourceHandler, bool) {
244+ switch requestPath {
236 case "/api/message":245 case "/api/message":
237 return h.handleMessageAPI(request), true246 return h.handleMessageAPI(request), true
238 case "/api/clipboard-set":247 case "/api/clipboard-set":
@@ -665,7 +674,7 @@ func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler {
665 return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found")674 return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found")
666 }675 }
667 676
668- data, headers, err := readAssetWithEncoding(h.assets, fullPath, relPath)677+ data, err := readAssetWithEncoding(h.assets, fullPath, relPath)
669 if err != nil {678 if err != nil {
670 h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")679 h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")
671 return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found")680 return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found")
@@ -678,7 +687,7 @@ func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler {
678 Int("size", len(data)).687 Int("size", len(data)).
679 Msg("serving asset")688 Msg("serving asset")
680 689
681- return newStaticResourceHandler(http.StatusOK, contentType, data, headers)690+ return newStaticResourceHandler(http.StatusOK, contentType, data, nil)
682 }691 }
683 692
684 func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath string, ok bool) {693 func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath string, ok bool) {
@@ -704,21 +713,21 @@ func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath
704 return fullPath, cleanRelPath, true713 return fullPath, cleanRelPath, true
705 }714 }
706 715
707-func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, map[string]string, error) {716+func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, error) {
708 if strings.HasSuffix(relPath, ".wasm") {717 if strings.HasSuffix(relPath, ".wasm") {
709 if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil {718 if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil {
710 data, err := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(compressed)), maxSystemviewsWASMBytes+1))719 data, err := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(compressed)), maxSystemviewsWASMBytes+1))
711 if err != nil {720 if err != nil {
712- return nil, nil, err721+ return nil, err
713 }722 }
714 if len(data) > maxSystemviewsWASMBytes {723 if len(data) > maxSystemviewsWASMBytes {
715- return nil, nil, fmt.Errorf("decompressed asset %s exceeds %d bytes", fullPath, maxSystemviewsWASMBytes)724+ return nil, fmt.Errorf("decompressed asset %s exceeds %d bytes", fullPath, maxSystemviewsWASMBytes)
716 }725 }
717- return data, nil, nil726+ return data, nil
718 }727 }
719 }728 }
720 data, err := fs.ReadFile(assets, fullPath)729 data, err := fs.ReadFile(assets, fullPath)
721- return data, nil, err730+ return data, err
722 }731 }
723 732
724 // resolveAssetPath maps either a dumb:// URL or the actual internal HTTPS URL733 // resolveAssetPath maps either a dumb:// URL or the actual internal HTTPS URL
@@ -741,15 +750,15 @@ func resolveConceptualAssetPath(u *url.URL) (assetDir, relPath string, ok bool)
741 }750 }
742 751
743 if root, ok := pageRootFiles[u.Host]; ok {752 if root, ok := pageRootFiles[u.Host]; ok {
744- path := strings.TrimPrefix(u.Path, "/")753+ assetPath := strings.TrimPrefix(u.Path, "/")
745- if path == "" {754+ if assetPath == "" {
746 return assetDirForPageHost(u.Host), root, true755 return assetDirForPageHost(u.Host), root, true
747 }756 }
748 // Don't serve API paths as assets.757 // Don't serve API paths as assets.
749- if strings.HasPrefix(path, "api/") {758+ if strings.HasPrefix(assetPath, "api/") {
750 return "", "", false759 return "", "", false
751 }760 }
752- return assetDirForPageHost(u.Host), path, true761+ return assetDirForPageHost(u.Host), assetPath, true
753 }762 }
754 763
755 // Handle opaque URLs like dumb:history.764 // Handle opaque URLs like dumb:history.
@@ -764,12 +773,12 @@ func resolveActualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
764 return "", "", false773 return "", "", false
765 }774 }
766 775
767- path := strings.Trim(u.Path, "/")776+ assetPath := strings.Trim(u.Path, "/")
768- if path == "" {777+ if assetPath == "" {
769 return "", "", false778 return "", "", false
770 }779 }
771 780
772- parts := strings.SplitN(path, "/", 2)781+ parts := strings.SplitN(assetPath, "/", 2)
773 page := parts[0]782 page := parts[0]
774 if root, ok := pageRootFiles[page]; ok {783 if root, ok := pageRootFiles[page]; ok {
775 if len(parts) == 1 {784 if len(parts) == 1 {
@@ -781,12 +790,12 @@ func resolveActualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
781 return assetDirForPageHost(page), parts[1], true790 return assetDirForPageHost(page), parts[1], true
782 }791 }
783 792
784- if strings.HasPrefix(path, "api/") {793+ if strings.HasPrefix(assetPath, "api/") {
785 return "", "", false794 return "", "", false
786 }795 }
787 796
788- if !strings.Contains(path, "/") && strings.Contains(path, ".") {797+ if !strings.Contains(assetPath, "/") && strings.Contains(assetPath, ".") {
789- return systemviewsAssetDir, path, true798+ return systemviewsAssetDir, assetPath, true
790 }799 }
791 800
792 return "", "", false801 return "", "", false
@@ -929,6 +938,79 @@ func newFaviconResourceHandler(service port.FaviconService, domain string, size
929 })938 })
930 }939 }
931 940
941+const maxFaviconMemoryCacheEntries = 256
942+
943+var systemviewFaviconCache = newFaviconMemoryCache(maxFaviconMemoryCacheEntries)
944+
945+type faviconCacheEntry struct {
946+ statusCode int
947+ contentType string
948+ data []byte
949+}
950+
951+type faviconMemoryCache struct {
952+ mu sync.Mutex
953+ max int
954+ entries map[string]faviconCacheEntry
955+ order []string
956+}
957+
958+func newFaviconMemoryCache(maxEntries int) *faviconMemoryCache {
959+ return &faviconMemoryCache{
960+ max: maxEntries,
961+ entries: make(map[string]faviconCacheEntry),
962+ order: make([]string, 0, maxEntries),
963+ }
964+}
965+
966+func faviconCacheKey(domain string, size int) string {
967+ return strings.ToLower(strings.TrimSpace(domain)) + "\x00" + strconv.Itoa(size)
968+}
969+
970+func (c *faviconMemoryCache) get(key string) (faviconCacheEntry, bool) {
971+ if c == nil || key == "" {
972+ return faviconCacheEntry{}, false
973+ }
974+ c.mu.Lock()
975+ defer c.mu.Unlock()
976+ entry, ok := c.entries[key]
977+ if !ok {
978+ return faviconCacheEntry{}, false
979+ }
980+ c.moveToBackLocked(key)
981+ return entry, true
982+}
983+
984+func (c *faviconMemoryCache) put(key string, entry faviconCacheEntry) {
985+ if c == nil || key == "" || c.max <= 0 {
986+ return
987+ }
988+ c.mu.Lock()
989+ defer c.mu.Unlock()
990+ if _, ok := c.entries[key]; !ok {
991+ c.order = append(c.order, key)
992+ } else {
993+ c.moveToBackLocked(key)
994+ }
995+ c.entries[key] = entry
996+ for len(c.order) > c.max {
997+ oldest := c.order[0]
998+ c.order = c.order[1:]
999+ delete(c.entries, oldest)
1000+ }
1001+}
1002+
1003+func (c *faviconMemoryCache) moveToBackLocked(key string) {
1004+ for i, candidate := range c.order {
1005+ if candidate != key {
1006+ continue
1007+ }
1008+ copy(c.order[i:], c.order[i+1:])
1009+ c.order[len(c.order)-1] = key
1010+ return
1011+ }
1012+}
1013+
932 type faviconResourceHandler struct {1014 type faviconResourceHandler struct {
933 service port.FaviconService1015 service port.FaviconService
934 domain string1016 domain string
@@ -947,20 +1029,35 @@ func (rh *faviconResourceHandler) load() {
947 rh.statusCode = http.StatusNotFound1029 rh.statusCode = http.StatusNotFound
948 rh.contentType = "application/json"1030 rh.contentType = "application/json"
949 rh.data = []byte(`{"error":"favicon not cached"}`)1031 rh.data = []byte(`{"error":"favicon not cached"}`)
950- if rh.service == nil || !rh.service.HasPNGSizedOnDisk(rh.domain, rh.size) {1032+ if rh.service == nil {
1033+ return
1034+ }
1035+ cacheKey := faviconCacheKey(rh.domain, rh.size)
1036+ if entry, ok := systemviewFaviconCache.get(cacheKey); ok {
1037+ rh.statusCode = entry.statusCode
1038+ rh.contentType = entry.contentType
1039+ rh.data = entry.data
1040+ return
1041+ }
1042+ if !rh.service.HasPNGSizedOnDisk(rh.domain, rh.size) {
951 return1043 return
952 }1044 }
953- path := rh.service.DiskPathPNGSized(rh.domain, rh.size)1045+ diskPath := rh.service.DiskPathPNGSized(rh.domain, rh.size)
954- if path == "" {1046+ if diskPath == "" {
955 return1047 return
956 }1048 }
957- data, err := os.ReadFile(path)1049+ data, err := os.ReadFile(diskPath)
958 if err != nil || len(data) == 0 {1050 if err != nil || len(data) == 0 {
959 return1051 return
960 }1052 }
961 rh.statusCode = http.StatusOK1053 rh.statusCode = http.StatusOK
962 rh.contentType = "image/png"1054 rh.contentType = "image/png"
963 rh.data = data1055 rh.data = data
1056+ systemviewFaviconCache.put(cacheKey, faviconCacheEntry{
1057+ statusCode: rh.statusCode,
1058+ contentType: rh.contentType,
1059+ data: data,
1060+ })
964 }1061 }
965 1062
966 func (rh *faviconResourceHandler) Open(_ purecef.Request, handleRequest *int32, callback purecef.Callback) int32 {1063 func (rh *faviconResourceHandler) Open(_ purecef.Request, handleRequest *int32, callback purecef.Callback) int32 {
M internal/infrastructure/cef/scheme_handler_systemviews_test.go +1 -4
@@ -45,16 +45,13 @@ func TestResolveAssetPath_SystemviewsRootsUseSystemviewsShell(t *testing.T) {
4545 func TestReadAssetWithEncoding_DecompressesEmbeddedBrotliWASM(t *testing.T) {
4646 t.Parallel()
4747
48- data, headers, err := readAssetWithEncoding(assets.WebUIAssets, "systemviews/systemviews.wasm", "systemviews.wasm")
48+ data, err := readAssetWithEncoding(assets.WebUIAssets, "systemviews/systemviews.wasm", "systemviews.wasm")
4949 if err != nil {
5050 t.Fatalf("readAssetWithEncoding() error = %v", err)
5151 }
5252 if len(data) < 4 || string(data[:4]) != "\x00asm" {
5353 t.Fatalf("decompressed wasm magic = %q, want \\x00asm", data[:4])
5454 }
55- if _, ok := headers["Content-Encoding"]; ok {
56- t.Fatalf("Content-Encoding header = %q, want absent after server-side decompression", headers["Content-Encoding"])
57- }
5855 }
5956
6057 func mustParseURL(t *testing.T, raw string) *url.URL {
@@ -45,16 +45,13 @@ func TestResolveAssetPath_SystemviewsRootsUseSystemviewsShell(t *testing.T) {
45 func TestReadAssetWithEncoding_DecompressesEmbeddedBrotliWASM(t *testing.T) {45 func TestReadAssetWithEncoding_DecompressesEmbeddedBrotliWASM(t *testing.T) {
46 t.Parallel()46 t.Parallel()
47 47
48- data, headers, err := readAssetWithEncoding(assets.WebUIAssets, "systemviews/systemviews.wasm", "systemviews.wasm")48+ data, err := readAssetWithEncoding(assets.WebUIAssets, "systemviews/systemviews.wasm", "systemviews.wasm")
49 if err != nil {49 if err != nil {
50 t.Fatalf("readAssetWithEncoding() error = %v", err)50 t.Fatalf("readAssetWithEncoding() error = %v", err)
51 }51 }
52 if len(data) < 4 || string(data[:4]) != "\x00asm" {52 if len(data) < 4 || string(data[:4]) != "\x00asm" {
53 t.Fatalf("decompressed wasm magic = %q, want \\x00asm", data[:4])53 t.Fatalf("decompressed wasm magic = %q, want \\x00asm", data[:4])
54 }54 }
55- if _, ok := headers["Content-Encoding"]; ok {
56- t.Fatalf("Content-Encoding header = %q, want absent after server-side decompression", headers["Content-Encoding"])
57- }
58 }55 }
59 56
60 func mustParseURL(t *testing.T, raw string) *url.URL {57 func mustParseURL(t *testing.T, raw string) *url.URL {
M internal/infrastructure/cef/transcoding_handler.go +3 -3
@@ -112,17 +112,17 @@ func (rh *transcodingResourceHandler) setStartErr(err error, status int) {
112112 rh.startErrStatus = status
113113 }
114114
115-func (rh *transcodingResourceHandler) getStartErr() (error, int) {
115+func (rh *transcodingResourceHandler) getStartErr() (int, error) {
116116 rh.mu.Lock()
117117 defer rh.mu.Unlock()
118- return rh.startErr, rh.startErrStatus
118+ return rh.startErrStatus, rh.startErr
119119 }
120120
121121 // GetResponseHeaders sets the streaming response metadata.
122122 func (rh *transcodingResourceHandler) GetResponseHeaders(
123123 response purecef.Response, responseLength *int64, _ uintptr,
124124 ) {
125- if startErr, status := rh.getStartErr(); startErr != nil {
125+ if status, startErr := rh.getStartErr(); startErr != nil {
126126 if status == 0 {
127127 status = httpStatusBadGateway
128128 }
@@ -112,17 +112,17 @@ func (rh *transcodingResourceHandler) setStartErr(err error, status int) {
112 rh.startErrStatus = status112 rh.startErrStatus = status
113 }113 }
114 114
115-func (rh *transcodingResourceHandler) getStartErr() (error, int) {115+func (rh *transcodingResourceHandler) getStartErr() (int, error) {
116 rh.mu.Lock()116 rh.mu.Lock()
117 defer rh.mu.Unlock()117 defer rh.mu.Unlock()
118- return rh.startErr, rh.startErrStatus118+ return rh.startErrStatus, rh.startErr
119 }119 }
120 120
121 // GetResponseHeaders sets the streaming response metadata.121 // GetResponseHeaders sets the streaming response metadata.
122 func (rh *transcodingResourceHandler) GetResponseHeaders(122 func (rh *transcodingResourceHandler) GetResponseHeaders(
123 response purecef.Response, responseLength *int64, _ uintptr,123 response purecef.Response, responseLength *int64, _ uintptr,
124 ) {124 ) {
125- if startErr, status := rh.getStartErr(); startErr != nil {125+ if status, startErr := rh.getStartErr(); startErr != nil {
126 if status == 0 {126 if status == 0 {
127 status = httpStatusBadGateway127 status = httpStatusBadGateway
128 }128 }
M internal/infrastructure/config/loader.go +2 -0
@@ -254,6 +254,8 @@ func normalizeEngineConfig(config *Config) {
254254 config.Engine.Profile = ProfileDefault
255255 case string(ProfileLite):
256256 config.Engine.Profile = ProfileLite
257+ case string(ProfileBalanced):
258+ config.Engine.Profile = ProfileBalanced
257259 case string(ProfileMax):
258260 config.Engine.Profile = ProfileMax
259261 case string(ProfileCustom):
@@ -254,6 +254,8 @@ func normalizeEngineConfig(config *Config) {
254 config.Engine.Profile = ProfileDefault254 config.Engine.Profile = ProfileDefault
255 case string(ProfileLite):255 case string(ProfileLite):
256 config.Engine.Profile = ProfileLite256 config.Engine.Profile = ProfileLite
257+ case string(ProfileBalanced):
258+ config.Engine.Profile = ProfileBalanced
257 case string(ProfileMax):259 case string(ProfileMax):
258 config.Engine.Profile = ProfileMax260 config.Engine.Profile = ProfileMax
259 case string(ProfileCustom):261 case string(ProfileCustom):
M internal/infrastructure/config/profile.go +9 -1
@@ -85,6 +85,8 @@ func ResolvePerformanceProfile(cfg *PerformanceConfig, hw *port.HardwareInfo) Re
8585 switch cfg.Profile {
8686 case ProfileLite:
8787 return resolveLiteProfile(hw)
88+ case ProfileBalanced:
89+ return resolveBalancedProfile()
8890 case ProfileMax:
8991 return resolveMaxProfile(hw)
9092 case ProfileCustom:
@@ -116,6 +118,12 @@ func resolveDefaultProfile() ResolvedPerformanceSettings {
116118 }
117119 }
118120
121+// resolveBalancedProfile preserves the legacy balanced profile as WebKit's
122+// default tuning with the normal WebView prewarm count.
123+func resolveBalancedProfile() ResolvedPerformanceSettings {
124+ return resolveDefaultProfile()
125+}
126+
119127 // resolveLiteProfile returns settings optimized for low-RAM systems.
120128 // Uses hardware info to scale CPU threads if available.
121129 func resolveLiteProfile(hw *port.HardwareInfo) ResolvedPerformanceSettings {
@@ -299,7 +307,7 @@ func HasCustomPerformanceFields(cfg *PerformanceConfig) bool {
299307 // IsValidPerformanceProfile returns true if the profile name is recognized.
300308 func IsValidPerformanceProfile(profile PerformanceProfile) bool {
301309 switch profile {
302- case ProfileDefault, ProfileLite, ProfileMax, ProfileCustom, "":
310+ case ProfileDefault, ProfileLite, ProfileBalanced, ProfileMax, ProfileCustom, "":
303311 return true
304312 default:
305313 return false
@@ -85,6 +85,8 @@ func ResolvePerformanceProfile(cfg *PerformanceConfig, hw *port.HardwareInfo) Re
85 switch cfg.Profile {85 switch cfg.Profile {
86 case ProfileLite:86 case ProfileLite:
87 return resolveLiteProfile(hw)87 return resolveLiteProfile(hw)
88+ case ProfileBalanced:
89+ return resolveBalancedProfile()
88 case ProfileMax:90 case ProfileMax:
89 return resolveMaxProfile(hw)91 return resolveMaxProfile(hw)
90 case ProfileCustom:92 case ProfileCustom:
@@ -116,6 +118,12 @@ func resolveDefaultProfile() ResolvedPerformanceSettings {
116 }118 }
117 }119 }
118 120
121+// resolveBalancedProfile preserves the legacy balanced profile as WebKit's
122+// default tuning with the normal WebView prewarm count.
123+func resolveBalancedProfile() ResolvedPerformanceSettings {
124+ return resolveDefaultProfile()
125+}
126+
119 // resolveLiteProfile returns settings optimized for low-RAM systems.127 // resolveLiteProfile returns settings optimized for low-RAM systems.
120 // Uses hardware info to scale CPU threads if available.128 // Uses hardware info to scale CPU threads if available.
121 func resolveLiteProfile(hw *port.HardwareInfo) ResolvedPerformanceSettings {129 func resolveLiteProfile(hw *port.HardwareInfo) ResolvedPerformanceSettings {
@@ -299,7 +307,7 @@ func HasCustomPerformanceFields(cfg *PerformanceConfig) bool {
299 // IsValidPerformanceProfile returns true if the profile name is recognized.307 // IsValidPerformanceProfile returns true if the profile name is recognized.
300 func IsValidPerformanceProfile(profile PerformanceProfile) bool {308 func IsValidPerformanceProfile(profile PerformanceProfile) bool {
301 switch profile {309 switch profile {
302- case ProfileDefault, ProfileLite, ProfileMax, ProfileCustom, "":310+ case ProfileDefault, ProfileLite, ProfileBalanced, ProfileMax, ProfileCustom, "":
303 return true311 return true
304 default:312 default:
305 return false313 return false
M internal/infrastructure/config/profile_test.go +17 -0
@@ -44,6 +44,22 @@ func TestResolvePerformanceProfile_Empty(t *testing.T) {
4444 }
4545 }
4646
47+func TestResolvePerformanceProfile_Balanced(t *testing.T) {
48+ cfg := &PerformanceConfig{Profile: ProfileBalanced}
49+
50+ result := ResolvePerformanceProfile(cfg, nil)
51+
52+ if result.SkiaCPUPaintingThreads != 0 {
53+ t.Errorf("expected SkiaCPUPaintingThreads=0, got %d", result.SkiaCPUPaintingThreads)
54+ }
55+ if result.SkiaGPUPaintingThreads != -1 {
56+ t.Errorf("expected SkiaGPUPaintingThreads=-1, got %d", result.SkiaGPUPaintingThreads)
57+ }
58+ if result.WebViewPoolPrewarmCount != 4 {
59+ t.Errorf("expected WebViewPoolPrewarmCount=4, got %d", result.WebViewPoolPrewarmCount)
60+ }
61+}
62+
4763 func TestResolvePerformanceProfile_Lite_NoHardware(t *testing.T) {
4864 cfg := &PerformanceConfig{
4965 Profile: ProfileLite,
@@ -317,6 +333,7 @@ func TestIsValidPerformanceProfile(t *testing.T) {
317333 }{
318334 {ProfileDefault, true},
319335 {ProfileLite, true},
336+ {ProfileBalanced, true},
320337 {ProfileMax, true},
321338 {ProfileCustom, true},
322339 {"", true}, // Empty is valid (treated as default)
@@ -44,6 +44,22 @@ func TestResolvePerformanceProfile_Empty(t *testing.T) {
44 }44 }
45 }45 }
46 46
47+func TestResolvePerformanceProfile_Balanced(t *testing.T) {
48+ cfg := &PerformanceConfig{Profile: ProfileBalanced}
49+
50+ result := ResolvePerformanceProfile(cfg, nil)
51+
52+ if result.SkiaCPUPaintingThreads != 0 {
53+ t.Errorf("expected SkiaCPUPaintingThreads=0, got %d", result.SkiaCPUPaintingThreads)
54+ }
55+ if result.SkiaGPUPaintingThreads != -1 {
56+ t.Errorf("expected SkiaGPUPaintingThreads=-1, got %d", result.SkiaGPUPaintingThreads)
57+ }
58+ if result.WebViewPoolPrewarmCount != 4 {
59+ t.Errorf("expected WebViewPoolPrewarmCount=4, got %d", result.WebViewPoolPrewarmCount)
60+ }
61+}
62+
47 func TestResolvePerformanceProfile_Lite_NoHardware(t *testing.T) {63 func TestResolvePerformanceProfile_Lite_NoHardware(t *testing.T) {
48 cfg := &PerformanceConfig{64 cfg := &PerformanceConfig{
49 Profile: ProfileLite,65 Profile: ProfileLite,
@@ -317,6 +333,7 @@ func TestIsValidPerformanceProfile(t *testing.T) {
317 }{333 }{
318 {ProfileDefault, true},334 {ProfileDefault, true},
319 {ProfileLite, true},335 {ProfileLite, true},
336+ {ProfileBalanced, true},
320 {ProfileMax, true},337 {ProfileMax, true},
321 {ProfileCustom, true},338 {ProfileCustom, true},
322 {"", true}, // Empty is valid (treated as default)339 {"", true}, // Empty is valid (treated as default)
M internal/infrastructure/config/schema.go +3 -1
@@ -170,6 +170,8 @@ const (
170170 ProfileDefault PerformanceProfile = "default"
171171 // ProfileLite reduces resource usage for low-RAM systems.
172172 ProfileLite PerformanceProfile = "lite"
173+ // ProfileBalanced is a conservative compatibility preset between lite and max.
174+ ProfileBalanced PerformanceProfile = "balanced"
173175 // ProfileMax maximizes responsiveness for heavy pages (GitHub PRs, etc).
174176 ProfileMax PerformanceProfile = "max"
175177 // ProfileCustom allows full control over individual performance settings.
@@ -226,7 +228,7 @@ type HistoryConfig struct {
226228 // These are not exposed in dumb://config UI but can be set in config file.
227229 type PerformanceConfig struct {
228230 // Profile selects a preset performance configuration.
229- // Values: "default" (no tuning), "lite" (low RAM), "max" (heavy pages), "custom" (manual control)
231+ // Values: "default" (no tuning), "lite" (low RAM), "balanced" (compatibility), "max" (heavy pages), "custom" (manual control)
230232 // When profile is not "custom", individual tuning fields below are ignored and computed from the profile.
231233 // Default: "default"
232234 Profile PerformanceProfile `mapstructure:"profile" yaml:"profile" toml:"profile"`
@@ -170,6 +170,8 @@ const (
170 ProfileDefault PerformanceProfile = "default"170 ProfileDefault PerformanceProfile = "default"
171 // ProfileLite reduces resource usage for low-RAM systems.171 // ProfileLite reduces resource usage for low-RAM systems.
172 ProfileLite PerformanceProfile = "lite"172 ProfileLite PerformanceProfile = "lite"
173+ // ProfileBalanced is a conservative compatibility preset between lite and max.
174+ ProfileBalanced PerformanceProfile = "balanced"
173 // ProfileMax maximizes responsiveness for heavy pages (GitHub PRs, etc).175 // ProfileMax maximizes responsiveness for heavy pages (GitHub PRs, etc).
174 ProfileMax PerformanceProfile = "max"176 ProfileMax PerformanceProfile = "max"
175 // ProfileCustom allows full control over individual performance settings.177 // ProfileCustom allows full control over individual performance settings.
@@ -226,7 +228,7 @@ type HistoryConfig struct {
226 // These are not exposed in dumb://config UI but can be set in config file.228 // These are not exposed in dumb://config UI but can be set in config file.
227 type PerformanceConfig struct {229 type PerformanceConfig struct {
228 // Profile selects a preset performance configuration.230 // Profile selects a preset performance configuration.
229- // Values: "default" (no tuning), "lite" (low RAM), "max" (heavy pages), "custom" (manual control)231+ // Values: "default" (no tuning), "lite" (low RAM), "balanced" (compatibility), "max" (heavy pages), "custom" (manual control)
230 // When profile is not "custom", individual tuning fields below are ignored and computed from the profile.232 // When profile is not "custom", individual tuning fields below are ignored and computed from the profile.
231 // Default: "default"233 // Default: "default"
232 Profile PerformanceProfile `mapstructure:"profile" yaml:"profile" toml:"profile"`234 Profile PerformanceProfile `mapstructure:"profile" yaml:"profile" toml:"profile"`
M internal/infrastructure/config/schema_provider.go +1 -1
@@ -905,7 +905,7 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn
905905 Type: "string",
906906 Default: string(defaults.Engine.Profile),
907907 Description: "Performance profile preset (custom enables manual tuning)",
908- Values: []string{"default", "lite", "max", "custom"},
908+ Values: []string{"default", "lite", "balanced", "max", "custom"},
909909 Section: SectionPerformance,
910910 },
911911 {
@@ -905,7 +905,7 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn
905 Type: "string",905 Type: "string",
906 Default: string(defaults.Engine.Profile),906 Default: string(defaults.Engine.Profile),
907 Description: "Performance profile preset (custom enables manual tuning)",907 Description: "Performance profile preset (custom enables manual tuning)",
908- Values: []string{"default", "lite", "max", "custom"},908+ Values: []string{"default", "lite", "balanced", "max", "custom"},
909 Section: SectionPerformance,909 Section: SectionPerformance,
910 },910 },
911 {911 {
M internal/infrastructure/config/validation.go +1 -1
@@ -483,7 +483,7 @@ func validatePerformanceProfile(config *Config) []string {
483483 // Validate profile name
484484 if !IsValidPerformanceProfile(config.Engine.Profile) {
485485 validationErrors = append(validationErrors, fmt.Sprintf(
486- "engine.profile must be one of: default, lite, max, custom (got: %s)",
486+ "engine.profile must be one of: default, lite, balanced, max, custom (got: %s)",
487487 config.Engine.Profile,
488488 ))
489489 }
@@ -483,7 +483,7 @@ func validatePerformanceProfile(config *Config) []string {
483 // Validate profile name483 // Validate profile name
484 if !IsValidPerformanceProfile(config.Engine.Profile) {484 if !IsValidPerformanceProfile(config.Engine.Profile) {
485 validationErrors = append(validationErrors, fmt.Sprintf(485 validationErrors = append(validationErrors, fmt.Sprintf(
486- "engine.profile must be one of: default, lite, max, custom (got: %s)",486+ "engine.profile must be one of: default, lite, balanced, max, custom (got: %s)",
487 config.Engine.Profile,487 config.Engine.Profile,
488 ))488 ))
489 }489 }
M internal/infrastructure/handlers/homepage/favorites.go +4 -0
@@ -278,6 +278,10 @@ func (h *FavoritesHandlers) HandleSetFolder() port.WebUIMessageHandler {
278278 Int64("favorite_id", req.FavoriteID).
279279 Msg("handling favorite_set_folder")
280280
281+ if req.FavoriteID <= 0 {
282+ return NewErrorResponse(req.RequestID, fmt.Errorf("invalid favorite_id")), nil
283+ }
284+
281285 folderID, err := folderIDFromInt64(req.FolderID)
282286 if err != nil {
283287 return NewErrorResponse(req.RequestID, err), nil
@@ -278,6 +278,10 @@ func (h *FavoritesHandlers) HandleSetFolder() port.WebUIMessageHandler {
278 Int64("favorite_id", req.FavoriteID).278 Int64("favorite_id", req.FavoriteID).
279 Msg("handling favorite_set_folder")279 Msg("handling favorite_set_folder")
280 280
281+ if req.FavoriteID <= 0 {
282+ return NewErrorResponse(req.RequestID, fmt.Errorf("invalid favorite_id")), nil
283+ }
284+
281 folderID, err := folderIDFromInt64(req.FolderID)285 folderID, err := folderIDFromInt64(req.FolderID)
282 if err != nil {286 if err != nil {
283 return NewErrorResponse(req.RequestID, err), nil287 return NewErrorResponse(req.RequestID, err), nil
M internal/infrastructure/handlers/homepage/history.go +10 -3
@@ -7,6 +7,7 @@ import (
77 "strings"
88 "time"
99
10+ "github.com/bnema/dumber/internal/application/dto"
1011 "github.com/bnema/dumber/internal/application/port"
1112 "github.com/bnema/dumber/internal/domain/entity"
1213 "github.com/bnema/dumber/internal/logging"
@@ -58,7 +59,10 @@ func (h *HistoryHandlers) HandleTimeline() port.WebUIMessageHandler {
5859 Int("offset", req.Offset).
5960 Msg("handling history_timeline")
6061 if req.Limit <= 0 {
61- return NewErrorResponse(req.RequestID, fmt.Errorf("history_timeline requires a positive limit; use history_timeline_window for lazy history loading")), nil
62+ return NewErrorResponse(
63+ req.RequestID,
64+ fmt.Errorf("history_timeline requires a positive limit; use history_timeline_window for lazy history loading"),
65+ ), nil
6266 }
6367
6468 entries, err := h.historyUC.GetRecent(ctx, req.Limit, req.Offset)
@@ -92,7 +96,10 @@ func (h *HistoryHandlers) HandleTimelineByDomain() port.WebUIMessageHandler {
9296 Int("offset", req.Offset).
9397 Msg("handling history_timeline_by_domain")
9498 if req.Limit <= 0 {
95- return NewErrorResponse(req.RequestID, fmt.Errorf("history_timeline_by_domain requires a positive limit; use history_timeline_window for lazy history loading")), nil
99+ return NewErrorResponse(
100+ req.RequestID,
101+ fmt.Errorf("history_timeline_by_domain requires a positive limit; use history_timeline_window for lazy history loading"),
102+ ), nil
96103 }
97104
98105 entries, err := h.historyUC.GetRecentByDomain(ctx, req.Domain, req.Limit, req.Offset)
@@ -162,7 +169,7 @@ func (h *HistoryHandlers) HandleSearchFTS() port.WebUIMessageHandler {
162169 Str("query", req.Query).
163170 Msg("handling history_search_fts")
164171
165- output, err := h.historyUC.Search(ctx, port.HistorySearchInput{
172+ output, err := h.historyUC.Search(ctx, dto.HistorySearchInput{
166173 Query: req.Query,
167174 Limit: req.Limit,
168175 })
@@ -7,6 +7,7 @@ import (
7 "strings"7 "strings"
8 "time"8 "time"
9 9
10+ "github.com/bnema/dumber/internal/application/dto"
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"
12 "github.com/bnema/dumber/internal/logging"13 "github.com/bnema/dumber/internal/logging"
@@ -58,7 +59,10 @@ func (h *HistoryHandlers) HandleTimeline() port.WebUIMessageHandler {
58 Int("offset", req.Offset).59 Int("offset", req.Offset).
59 Msg("handling history_timeline")60 Msg("handling history_timeline")
60 if req.Limit <= 0 {61 if req.Limit <= 0 {
61- return NewErrorResponse(req.RequestID, fmt.Errorf("history_timeline requires a positive limit; use history_timeline_window for lazy history loading")), nil62+ return NewErrorResponse(
63+ req.RequestID,
64+ fmt.Errorf("history_timeline requires a positive limit; use history_timeline_window for lazy history loading"),
65+ ), nil
62 }66 }
63 67
64 entries, err := h.historyUC.GetRecent(ctx, req.Limit, req.Offset)68 entries, err := h.historyUC.GetRecent(ctx, req.Limit, req.Offset)
@@ -92,7 +96,10 @@ func (h *HistoryHandlers) HandleTimelineByDomain() port.WebUIMessageHandler {
92 Int("offset", req.Offset).96 Int("offset", req.Offset).
93 Msg("handling history_timeline_by_domain")97 Msg("handling history_timeline_by_domain")
94 if req.Limit <= 0 {98 if req.Limit <= 0 {
95- return NewErrorResponse(req.RequestID, fmt.Errorf("history_timeline_by_domain requires a positive limit; use history_timeline_window for lazy history loading")), nil99+ return NewErrorResponse(
100+ req.RequestID,
101+ fmt.Errorf("history_timeline_by_domain requires a positive limit; use history_timeline_window for lazy history loading"),
102+ ), nil
96 }103 }
97 104
98 entries, err := h.historyUC.GetRecentByDomain(ctx, req.Domain, req.Limit, req.Offset)105 entries, err := h.historyUC.GetRecentByDomain(ctx, req.Domain, req.Limit, req.Offset)
@@ -162,7 +169,7 @@ func (h *HistoryHandlers) HandleSearchFTS() port.WebUIMessageHandler {
162 Str("query", req.Query).169 Str("query", req.Query).
163 Msg("handling history_search_fts")170 Msg("handling history_search_fts")
164 171
165- output, err := h.historyUC.Search(ctx, port.HistorySearchInput{172+ output, err := h.historyUC.Search(ctx, dto.HistorySearchInput{
166 Query: req.Query,173 Query: req.Query,
167 Limit: req.Limit,174 Limit: req.Limit,
168 })175 })
M internal/infrastructure/persistence/sqlite/lazy_repos.go +6 -1
@@ -163,7 +163,12 @@ func (r *LazyHistoryRepository) GetRecentWindow(ctx context.Context, before, aft
163163 return r.repo.GetRecentWindow(ctx, before, after)
164164 }
165165
166-func (r *LazyHistoryRepository) GetRecentWindowByDomain(ctx context.Context, domain string, before, after time.Time) ([]*entity.HistoryEntry, error) {
166+func (r *LazyHistoryRepository) GetRecentWindowByDomain(
167+ ctx context.Context,
168+ domain string,
169+ before time.Time,
170+ after time.Time,
171+) ([]*entity.HistoryEntry, error) {
167172 if err := r.init(ctx); err != nil {
168173 return nil, err
169174 }
@@ -163,7 +163,12 @@ func (r *LazyHistoryRepository) GetRecentWindow(ctx context.Context, before, aft
163 return r.repo.GetRecentWindow(ctx, before, after)163 return r.repo.GetRecentWindow(ctx, before, after)
164 }164 }
165 165
166-func (r *LazyHistoryRepository) GetRecentWindowByDomain(ctx context.Context, domain string, before, after time.Time) ([]*entity.HistoryEntry, error) {166+func (r *LazyHistoryRepository) GetRecentWindowByDomain(
167+ ctx context.Context,
168+ domain string,
169+ before time.Time,
170+ after time.Time,
171+) ([]*entity.HistoryEntry, error) {
167 if err := r.init(ctx); err != nil {172 if err := r.init(ctx); err != nil {
168 return nil, err173 return nil, err
169 }174 }
M internal/infrastructure/systemviewsbridge/client.go +4 -3
@@ -416,10 +416,11 @@ func (c *Client) UpdateTag(ctx context.Context, id int64, name, color string) er
416416 if id <= 0 {
417417 return fmt.Errorf("update tag id must be positive, got %d", id)
418418 }
419- var namePtr *string
420- if trimmedName := strings.TrimSpace(name); trimmedName != "" {
421- namePtr = &trimmedName
419+ trimmedName := strings.TrimSpace(name)
420+ if trimmedName == "" {
421+ return fmt.Errorf("tag name is required")
422422 }
423+ namePtr := &trimmedName
423424 var colorPtr *string
424425 if trimmedColor := strings.TrimSpace(color); trimmedColor != "" {
425426 colorPtr = &trimmedColor
@@ -416,10 +416,11 @@ func (c *Client) UpdateTag(ctx context.Context, id int64, name, color string) er
416 if id <= 0 {416 if id <= 0 {
417 return fmt.Errorf("update tag id must be positive, got %d", id)417 return fmt.Errorf("update tag id must be positive, got %d", id)
418 }418 }
419- var namePtr *string419+ trimmedName := strings.TrimSpace(name)
420- if trimmedName := strings.TrimSpace(name); trimmedName != "" {420+ if trimmedName == "" {
421- namePtr = &trimmedName421+ return fmt.Errorf("tag name is required")
422 }422 }
423+ namePtr := &trimmedName
423 var colorPtr *string424 var colorPtr *string
424 if trimmedColor := strings.TrimSpace(color); trimmedColor != "" {425 if trimmedColor := strings.TrimSpace(color); trimmedColor != "" {
425 colorPtr = &trimmedColor426 colorPtr = &trimmedColor
M internal/infrastructure/systemviewsbridge/client_test.go +4 -3
@@ -316,7 +316,7 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) {
316316 }{
317317 {
318318 name: "save config",
319- call: func(t *testing.T, client *Client) error {
319+ call: func(_ *testing.T, client *Client) error {
320320 return client.Save(context.Background(), dto.WebUIConfig{DefaultSearchEngine: "DuckDuckGo"})
321321 },
322322 want: "save_config",
@@ -353,7 +353,7 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) {
353353 },
354354 {
355355 name: "reset keybinding",
356- call: func(t *testing.T, client *Client) error {
356+ call: func(_ *testing.T, client *Client) error {
357357 return client.ResetKeybinding(context.Background(), port.ResetKeybindingRequest{RequestID: "req-2", Mode: "default", Action: "open"})
358358 },
359359 want: "reset_keybinding",
@@ -361,7 +361,7 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) {
361361 },
362362 {
363363 name: "reset all keybindings",
364- call: func(t *testing.T, client *Client) error {
364+ call: func(_ *testing.T, client *Client) error {
365365 return client.ResetAllKeybindings(context.Background())
366366 },
367367 want: "reset_all_keybindings",
@@ -657,6 +657,7 @@ func TestClientRejectsInvalidFavoriteMutationIDs(t *testing.T) {
657657 {name: "delete folder", call: func() error { return client.DeleteFolder(context.Background(), 0) }},
658658 {name: "create tag", call: func() error { _, err := client.CreateTag(context.Background(), " ", ""); return err }},
659659 {name: "update tag", call: func() error { return client.UpdateTag(context.Background(), 0, "Tag", "") }},
660+ {name: "update tag name", call: func() error { return client.UpdateTag(context.Background(), 1, " ", "") }},
660661 {name: "delete tag", call: func() error { return client.DeleteTag(context.Background(), 0) }},
661662 {name: "assign favorite", call: func() error { return client.AssignTag(context.Background(), 0, 1) }},
662663 {name: "assign tag", call: func() error { return client.AssignTag(context.Background(), 1, 0) }},
@@ -316,7 +316,7 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) {
316 }{316 }{
317 {317 {
318 name: "save config",318 name: "save config",
319- call: func(t *testing.T, client *Client) error {319+ call: func(_ *testing.T, client *Client) error {
320 return client.Save(context.Background(), dto.WebUIConfig{DefaultSearchEngine: "DuckDuckGo"})320 return client.Save(context.Background(), dto.WebUIConfig{DefaultSearchEngine: "DuckDuckGo"})
321 },321 },
322 want: "save_config",322 want: "save_config",
@@ -353,7 +353,7 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) {
353 },353 },
354 {354 {
355 name: "reset keybinding",355 name: "reset keybinding",
356- call: func(t *testing.T, client *Client) error {356+ call: func(_ *testing.T, client *Client) error {
357 return client.ResetKeybinding(context.Background(), port.ResetKeybindingRequest{RequestID: "req-2", Mode: "default", Action: "open"})357 return client.ResetKeybinding(context.Background(), port.ResetKeybindingRequest{RequestID: "req-2", Mode: "default", Action: "open"})
358 },358 },
359 want: "reset_keybinding",359 want: "reset_keybinding",
@@ -361,7 +361,7 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) {
361 },361 },
362 {362 {
363 name: "reset all keybindings",363 name: "reset all keybindings",
364- call: func(t *testing.T, client *Client) error {364+ call: func(_ *testing.T, client *Client) error {
365 return client.ResetAllKeybindings(context.Background())365 return client.ResetAllKeybindings(context.Background())
366 },366 },
367 want: "reset_all_keybindings",367 want: "reset_all_keybindings",
@@ -657,6 +657,7 @@ func TestClientRejectsInvalidFavoriteMutationIDs(t *testing.T) {
657 {name: "delete folder", call: func() error { return client.DeleteFolder(context.Background(), 0) }},657 {name: "delete folder", call: func() error { return client.DeleteFolder(context.Background(), 0) }},
658 {name: "create tag", call: func() error { _, err := client.CreateTag(context.Background(), " ", ""); return err }},658 {name: "create tag", call: func() error { _, err := client.CreateTag(context.Background(), " ", ""); return err }},
659 {name: "update tag", call: func() error { return client.UpdateTag(context.Background(), 0, "Tag", "") }},659 {name: "update tag", call: func() error { return client.UpdateTag(context.Background(), 0, "Tag", "") }},
660+ {name: "update tag name", call: func() error { return client.UpdateTag(context.Background(), 1, " ", "") }},
660 {name: "delete tag", call: func() error { return client.DeleteTag(context.Background(), 0) }},661 {name: "delete tag", call: func() error { return client.DeleteTag(context.Background(), 0) }},
661 {name: "assign favorite", call: func() error { return client.AssignTag(context.Background(), 0, 1) }},662 {name: "assign favorite", call: func() error { return client.AssignTag(context.Background(), 0, 1) }},
662 {name: "assign tag", call: func() error { return client.AssignTag(context.Background(), 1, 0) }},663 {name: "assign tag", call: func() error { return client.AssignTag(context.Background(), 1, 0) }},
M internal/infrastructure/transcoder/audio.go +2 -0
@@ -1,3 +1,5 @@
1+//go:build !js || !wasm
2+
13 package transcoder
24
35 import (
@@ -1,3 +1,5 @@
1+//go:build !js || !wasm
2+
1 package transcoder3 package transcoder
2 4
3 import (5 import (
M internal/infrastructure/transcoder/ffmpeg_offsets.go +2 -0
@@ -1,3 +1,5 @@
1+//go:build !js || !wasm
2+
13 package transcoder
24
35 // Offset-based accessors for FFmpeg struct fields not yet exposed
@@ -1,3 +1,5 @@
1+//go:build !js || !wasm
2+
1 package transcoder3 package transcoder
2 4
3 // Offset-based accessors for FFmpeg struct fields not yet exposed5 // Offset-based accessors for FFmpeg struct fields not yet exposed
M internal/infrastructure/transcoder/http_client_test.go +2 -0
@@ -1,3 +1,5 @@
1+//go:build !js || !wasm
2+
13 package transcoder
24
35 import (
@@ -1,3 +1,5 @@
1+//go:build !js || !wasm
2+
1 package transcoder3 package transcoder
2 4
3 import (5 import (
M internal/infrastructure/transcoder/hwprobe.go +2 -0
@@ -1,3 +1,5 @@
1+//go:build !js || !wasm
2+
13 package transcoder
24
35 import (
@@ -1,3 +1,5 @@
1+//go:build !js || !wasm
2+
1 package transcoder3 package transcoder
2 4
3 import (5 import (
M internal/infrastructure/transcoder/hwprobe_test.go +2 -0
@@ -1,3 +1,5 @@
1+//go:build !js || !wasm
2+
13 package transcoder
24
35 import "testing"
@@ -1,3 +1,5 @@
1+//go:build !js || !wasm
2+
1 package transcoder3 package transcoder
2 4
3 import "testing"5 import "testing"
M internal/infrastructure/transcoder/pipeline.go +6 -107
@@ -1,3 +1,5 @@
1+//go:build !js || !wasm
2+
13 package transcoder
24
35 import (
@@ -8,7 +10,6 @@ import (
810 "net"
911 "net/http"
1012 "net/url"
11- "sort"
1213 "strings"
1314 "time"
1415 "unsafe"
@@ -72,7 +73,7 @@ func validateTranscodeRedirect(req *http.Request, via []*http.Request) error {
7273 }
7374
7475 func safeTranscodeDialContext(ctx context.Context, network, address string) (net.Conn, error) {
75- host, port, err := net.SplitHostPort(address)
76+ host, portValue, err := net.SplitHostPort(address)
7677 if err != nil {
7778 return nil, err
7879 }
@@ -85,7 +86,7 @@ func safeTranscodeDialContext(ctx context.Context, network, address string) (net
8586 dialer := &net.Dialer{Timeout: 10 * time.Second}
8687 var lastErr error
8788 for _, ip := range ips {
88- conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
89+ conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), portValue))
8990 if dialErr == nil {
9091 return conn, nil
9192 }
@@ -581,110 +582,6 @@ func (p *pipeline) openCustomIOInputContext(ctx context.Context) (unsafe.Pointer
581582 return inFmtCtx, cleanup, nil
582583 }
583584
584-func (p *pipeline) openManifestInputContext() (unsafe.Pointer, func(), error) {
585- inFmtCtx := ffmpeg.FormatAllocContext()
586- if inFmtCtx == nil {
587- return nil, nil, errors.New("failed to allocate input format context")
588- }
589-
590- var opts unsafe.Pointer
591- defer freeInputOptions(&opts)
592-
593- if err := setInputOption(&opts, "protocol_whitelist", "file,http,https,tcp,tls,crypto"); err != nil {
594- ffmpeg.FormatFreeContext(inFmtCtx)
595- return nil, nil, err
596- }
597- if userAgent := p.headers["User-Agent"]; userAgent != "" {
598- if err := setInputOption(&opts, "user_agent", userAgent); err != nil {
599- ffmpeg.FormatFreeContext(inFmtCtx)
600- return nil, nil, err
601- }
602- }
603- if referer := p.headers["Referer"]; referer != "" {
604- if err := setInputOption(&opts, "referer", referer); err != nil {
605- ffmpeg.FormatFreeContext(inFmtCtx)
606- return nil, nil, err
607- }
608- }
609-
610- headerLines := formatHTTPHeaderOptions(p.headers)
611- if headerLines != "" {
612- if err := setInputOption(&opts, "headers", headerLines); err != nil {
613- ffmpeg.FormatFreeContext(inFmtCtx)
614- return nil, nil, err
615- }
616- }
617-
618- //nolint:gosec // FFmpeg requires pointer-to-pointer input opening for format contexts and option dictionaries.
619- if ret := ffmpeg.FormatOpenInput(unsafe.Pointer(&inFmtCtx), p.sourceURL, nil, unsafe.Pointer(&opts)); ret < 0 {
620- ffmpeg.FormatFreeContext(inFmtCtx)
621- return nil, nil, fmt.Errorf("open manifest input: %d", ret)
622- }
623-
624- return inFmtCtx, func() {
625- //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for format contexts.
626- ffmpeg.FormatCloseInput(unsafe.Pointer(&inFmtCtx))
627- }, nil
628-}
629-
630-func freeInputOptions(opts *unsafe.Pointer) {
631- if opts == nil || *opts == nil {
632- return
633- }
634- // av_dict_free expects an AVDictionary**. Passing the dictionary pointer
635- // itself corrupts memory and can crash inside libavutil during cleanup.
636- //nolint:gosec // FFmpeg requires passing the dictionary pointer by address for cleanup.
637- ffmpeg.DictFree(unsafe.Pointer(opts))
638- *opts = nil
639-}
640-
641-func setInputOption(opts *unsafe.Pointer, key, value string) error {
642- if value == "" {
643- return nil
644- }
645- //nolint:gosec // FFmpeg requires passing the dictionary pointer by address for option updates.
646- if ret := ffmpeg.DictSet(unsafe.Pointer(opts), key, value, 0); ret < 0 {
647- return fmt.Errorf("set input option %q: %d", key, ret)
648- }
649- return nil
650-}
651-
652-func formatHTTPHeaderOptions(headers map[string]string) string {
653- if len(headers) == 0 {
654- return ""
655- }
656-
657- keys := make([]string, 0, len(headers))
658- for key, value := range headers {
659- if strings.TrimSpace(value) == "" {
660- continue
661- }
662- if strings.EqualFold(key, "User-Agent") || strings.EqualFold(key, "Referer") {
663- continue
664- }
665- keys = append(keys, key)
666- }
667- if len(keys) == 0 {
668- return ""
669- }
670-
671- sort.Strings(keys)
672-
673- var builder strings.Builder
674- for _, key := range keys {
675- builder.WriteString(key)
676- builder.WriteString(": ")
677- builder.WriteString(headers[key])
678- builder.WriteString("\r\n")
679- }
680- return builder.String()
681-}
682-
683-// openVideoDecoder creates and opens a decoder context for the video stream.
684-// It prefers hardware decoders (h264_vaapi, h264_cuvid) based on hwCaps,
685-// falling back to the generic software decoder.
686-//
687-//nolint:gosec // FFmpeg decoder setup requires unsafe pointer-based contexts and cleanup.
688585 func (p *pipeline) openVideoDecoder(codecPar unsafe.Pointer) (unsafe.Pointer, error) {
689586 codecID := ffmpeg.CodecParCodecID(codecPar)
690587
@@ -711,11 +608,13 @@ func (p *pipeline) openVideoDecoder(codecPar unsafe.Pointer) (unsafe.Pointer, er
711608 }
712609
713610 if ret := ffmpeg.CodecParametersToContext(decCtx, codecPar); ret < 0 {
611+ //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts.
714612 ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
715613 return nil, fmt.Errorf("copy video codec params: %d", ret)
716614 }
717615
718616 if ret := ffmpeg.CodecOpen2(decCtx, dec, nil); ret < 0 {
617+ //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts.
719618 ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
720619 return nil, fmt.Errorf("open video decoder: %d", ret)
721620 }
@@ -1,3 +1,5 @@
1+//go:build !js || !wasm
2+
1 package transcoder3 package transcoder
2 4
3 import (5 import (
@@ -8,7 +10,6 @@ import (
8 "net"10 "net"
9 "net/http"11 "net/http"
10 "net/url"12 "net/url"
11- "sort"
12 "strings"13 "strings"
13 "time"14 "time"
14 "unsafe"15 "unsafe"
@@ -72,7 +73,7 @@ func validateTranscodeRedirect(req *http.Request, via []*http.Request) error {
72 }73 }
73 74
74 func safeTranscodeDialContext(ctx context.Context, network, address string) (net.Conn, error) {75 func safeTranscodeDialContext(ctx context.Context, network, address string) (net.Conn, error) {
75- host, port, err := net.SplitHostPort(address)76+ host, portValue, err := net.SplitHostPort(address)
76 if err != nil {77 if err != nil {
77 return nil, err78 return nil, err
78 }79 }
@@ -85,7 +86,7 @@ func safeTranscodeDialContext(ctx context.Context, network, address string) (net
85 dialer := &net.Dialer{Timeout: 10 * time.Second}86 dialer := &net.Dialer{Timeout: 10 * time.Second}
86 var lastErr error87 var lastErr error
87 for _, ip := range ips {88 for _, ip := range ips {
88- conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))89+ conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), portValue))
89 if dialErr == nil {90 if dialErr == nil {
90 return conn, nil91 return conn, nil
91 }92 }
@@ -581,110 +582,6 @@ func (p *pipeline) openCustomIOInputContext(ctx context.Context) (unsafe.Pointer
581 return inFmtCtx, cleanup, nil582 return inFmtCtx, cleanup, nil
582 }583 }
583 584
584-func (p *pipeline) openManifestInputContext() (unsafe.Pointer, func(), error) {
585- inFmtCtx := ffmpeg.FormatAllocContext()
586- if inFmtCtx == nil {
587- return nil, nil, errors.New("failed to allocate input format context")
588- }
589-
590- var opts unsafe.Pointer
591- defer freeInputOptions(&opts)
592-
593- if err := setInputOption(&opts, "protocol_whitelist", "file,http,https,tcp,tls,crypto"); err != nil {
594- ffmpeg.FormatFreeContext(inFmtCtx)
595- return nil, nil, err
596- }
597- if userAgent := p.headers["User-Agent"]; userAgent != "" {
598- if err := setInputOption(&opts, "user_agent", userAgent); err != nil {
599- ffmpeg.FormatFreeContext(inFmtCtx)
600- return nil, nil, err
601- }
602- }
603- if referer := p.headers["Referer"]; referer != "" {
604- if err := setInputOption(&opts, "referer", referer); err != nil {
605- ffmpeg.FormatFreeContext(inFmtCtx)
606- return nil, nil, err
607- }
608- }
609-
610- headerLines := formatHTTPHeaderOptions(p.headers)
611- if headerLines != "" {
612- if err := setInputOption(&opts, "headers", headerLines); err != nil {
613- ffmpeg.FormatFreeContext(inFmtCtx)
614- return nil, nil, err
615- }
616- }
617-
618- //nolint:gosec // FFmpeg requires pointer-to-pointer input opening for format contexts and option dictionaries.
619- if ret := ffmpeg.FormatOpenInput(unsafe.Pointer(&inFmtCtx), p.sourceURL, nil, unsafe.Pointer(&opts)); ret < 0 {
620- ffmpeg.FormatFreeContext(inFmtCtx)
621- return nil, nil, fmt.Errorf("open manifest input: %d", ret)
622- }
623-
624- return inFmtCtx, func() {
625- //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for format contexts.
626- ffmpeg.FormatCloseInput(unsafe.Pointer(&inFmtCtx))
627- }, nil
628-}
629-
630-func freeInputOptions(opts *unsafe.Pointer) {
631- if opts == nil || *opts == nil {
632- return
633- }
634- // av_dict_free expects an AVDictionary**. Passing the dictionary pointer
635- // itself corrupts memory and can crash inside libavutil during cleanup.
636- //nolint:gosec // FFmpeg requires passing the dictionary pointer by address for cleanup.
637- ffmpeg.DictFree(unsafe.Pointer(opts))
638- *opts = nil
639-}
640-
641-func setInputOption(opts *unsafe.Pointer, key, value string) error {
642- if value == "" {
643- return nil
644- }
645- //nolint:gosec // FFmpeg requires passing the dictionary pointer by address for option updates.
646- if ret := ffmpeg.DictSet(unsafe.Pointer(opts), key, value, 0); ret < 0 {
647- return fmt.Errorf("set input option %q: %d", key, ret)
648- }
649- return nil
650-}
651-
652-func formatHTTPHeaderOptions(headers map[string]string) string {
653- if len(headers) == 0 {
654- return ""
655- }
656-
657- keys := make([]string, 0, len(headers))
658- for key, value := range headers {
659- if strings.TrimSpace(value) == "" {
660- continue
661- }
662- if strings.EqualFold(key, "User-Agent") || strings.EqualFold(key, "Referer") {
663- continue
664- }
665- keys = append(keys, key)
666- }
667- if len(keys) == 0 {
668- return ""
669- }
670-
671- sort.Strings(keys)
672-
673- var builder strings.Builder
674- for _, key := range keys {
675- builder.WriteString(key)
676- builder.WriteString(": ")
677- builder.WriteString(headers[key])
678- builder.WriteString("\r\n")
679- }
680- return builder.String()
681-}
682-
683-// openVideoDecoder creates and opens a decoder context for the video stream.
684-// It prefers hardware decoders (h264_vaapi, h264_cuvid) based on hwCaps,
685-// falling back to the generic software decoder.
686-//
687-//nolint:gosec // FFmpeg decoder setup requires unsafe pointer-based contexts and cleanup.
688 func (p *pipeline) openVideoDecoder(codecPar unsafe.Pointer) (unsafe.Pointer, error) {585 func (p *pipeline) openVideoDecoder(codecPar unsafe.Pointer) (unsafe.Pointer, error) {
689 codecID := ffmpeg.CodecParCodecID(codecPar)586 codecID := ffmpeg.CodecParCodecID(codecPar)
690 587
@@ -711,11 +608,13 @@ func (p *pipeline) openVideoDecoder(codecPar unsafe.Pointer) (unsafe.Pointer, er
711 }608 }
712 609
713 if ret := ffmpeg.CodecParametersToContext(decCtx, codecPar); ret < 0 {610 if ret := ffmpeg.CodecParametersToContext(decCtx, codecPar); ret < 0 {
611+ //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts.
714 ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))612 ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
715 return nil, fmt.Errorf("copy video codec params: %d", ret)613 return nil, fmt.Errorf("copy video codec params: %d", ret)
716 }614 }
717 615
718 if ret := ffmpeg.CodecOpen2(decCtx, dec, nil); ret < 0 {616 if ret := ffmpeg.CodecOpen2(decCtx, dec, nil); ret < 0 {
617+ //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts.
719 ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))618 ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
720 return nil, fmt.Errorf("open video decoder: %d", ret)619 return nil, fmt.Errorf("open video decoder: %d", ret)
721 }620 }
M internal/infrastructure/transcoder/transcoder.go +2 -0
@@ -1,3 +1,5 @@
1+//go:build !js || !wasm
2+
13 package transcoder
24
35 import (
@@ -1,3 +1,5 @@
1+//go:build !js || !wasm
2+
1 package transcoder3 package transcoder
2 4
3 import (5 import (
A internal/infrastructure/transcoder/transcoder_wasm.go +41 -0
@@ -0,0 +1,41 @@
1+//go:build js && wasm
2+
3+package transcoder
4+
5+import (
6+ "context"
7+ "errors"
8+
9+ "github.com/rs/zerolog"
10+
11+ "github.com/bnema/dumber/internal/application/port"
12+)
13+
14+// Compile-time check: Transcoder implements port.MediaTranscoder.
15+var _ port.MediaTranscoder = (*Transcoder)(nil)
16+
17+// Transcoder is unavailable in js/wasm builds because FFmpeg is not available.
18+type Transcoder struct{}
19+
20+// New creates an unavailable Transcoder for js/wasm builds.
21+func New(_ any, _ *zerolog.Logger) *Transcoder {
22+ return &Transcoder{}
23+}
24+
25+// Available reports whether transcoding is available.
26+func (*Transcoder) Available() bool {
27+ return false
28+}
29+
30+// Capabilities returns empty hardware capabilities for js/wasm builds.
31+func (*Transcoder) Capabilities() port.HWCapabilities {
32+ return port.HWCapabilities{}
33+}
34+
35+// Start always fails for js/wasm builds.
36+func (*Transcoder) Start(_ context.Context, _ string, _ map[string]string) (port.TranscodeSession, error) {
37+ return nil, errors.New("transcoder: FFmpeg unavailable in js/wasm")
38+}
39+
40+// Close is a no-op for js/wasm builds.
41+func (*Transcoder) Close() {}
@@ -0,0 +1,41 @@
1+//go:build js && wasm
2+
3+package transcoder
4+
5+import (
6+ "context"
7+ "errors"
8+
9+ "github.com/rs/zerolog"
10+
11+ "github.com/bnema/dumber/internal/application/port"
12+)
13+
14+// Compile-time check: Transcoder implements port.MediaTranscoder.
15+var _ port.MediaTranscoder = (*Transcoder)(nil)
16+
17+// Transcoder is unavailable in js/wasm builds because FFmpeg is not available.
18+type Transcoder struct{}
19+
20+// New creates an unavailable Transcoder for js/wasm builds.
21+func New(_ any, _ *zerolog.Logger) *Transcoder {
22+ return &Transcoder{}
23+}
24+
25+// Available reports whether transcoding is available.
26+func (*Transcoder) Available() bool {
27+ return false
28+}
29+
30+// Capabilities returns empty hardware capabilities for js/wasm builds.
31+func (*Transcoder) Capabilities() port.HWCapabilities {
32+ return port.HWCapabilities{}
33+}
34+
35+// Start always fails for js/wasm builds.
36+func (*Transcoder) Start(_ context.Context, _ string, _ map[string]string) (port.TranscodeSession, error) {
37+ return nil, errors.New("transcoder: FFmpeg unavailable in js/wasm")
38+}
39+
40+// Close is a no-op for js/wasm builds.
41+func (*Transcoder) Close() {}
M internal/infrastructure/webkit/scheme_handler.go +21 -21
@@ -238,11 +238,11 @@ func (h *DumbSchemeHandler) handleFaviconAPI(req *SchemeRequest) *SchemeResponse
238238 if !service.HasPNGSizedOnDisk(domain, size) {
239239 return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached")
240240 }
241- path := service.DiskPathPNGSized(domain, size)
242- if path == "" {
241+ diskPath := service.DiskPathPNGSized(domain, size)
242+ if diskPath == "" {
243243 return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached")
244244 }
245- data, err := os.ReadFile(path)
245+ data, err := os.ReadFile(diskPath)
246246 if err != nil || len(data) == 0 {
247247 return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached")
248248 }
@@ -330,11 +330,11 @@ func buildConfigResponse(build func() ([]byte, error)) *SchemeResponse {
330330 }
331331
332332 // RegisterPage registers a handler for a specific path.
333-func (h *DumbSchemeHandler) RegisterPage(path string, handler PageHandler) {
333+func (h *DumbSchemeHandler) RegisterPage(pagePath string, handler PageHandler) {
334334 h.mu.Lock()
335335 defer h.mu.Unlock()
336- h.handlers[path] = handler
337- h.logger.Debug().Str("path", path).Msg("registered page handler")
336+ h.handlers[pagePath] = handler
337+ h.logger.Debug().Str("path", pagePath).Msg("registered page handler")
338338 }
339339
340340 // HandleRequest processes a scheme request and sends the response.
@@ -443,7 +443,7 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
443443 return nil
444444 }
445445
446- data, headers, err := readAssetWithEncoding(h.assets, fullPath, relPath)
446+ data, err := readAssetWithEncoding(h.assets, fullPath, relPath)
447447 if err != nil {
448448 h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")
449449 return nil
@@ -460,7 +460,7 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
460460 Data: data,
461461 ContentType: contentType,
462462 StatusCode: http.StatusOK,
463- Headers: headers,
463+ Headers: nil,
464464 }
465465 }
466466
@@ -487,7 +487,7 @@ func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath
487487 return fullPath, cleanRelPath, true
488488 }
489489
490-func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, map[string]string, error) {
490+func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, error) {
491491 var compressedErr error
492492 if strings.HasSuffix(relPath, ".wasm") {
493493 if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil {
@@ -497,18 +497,18 @@ func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, m
497497 } else if len(data) > maxSystemviewsWASMBytes {
498498 compressedErr = fmt.Errorf("decompressed asset %s exceeds %d bytes", fullPath, maxSystemviewsWASMBytes)
499499 } else {
500- return data, nil, nil
500+ return data, nil
501501 }
502502 }
503503 }
504504 data, err := fs.ReadFile(assets, fullPath)
505505 if err == nil {
506- return data, nil, nil
506+ return data, nil
507507 }
508508 if compressedErr != nil {
509- return nil, nil, compressedErr
509+ return nil, compressedErr
510510 }
511- return nil, nil, err
511+ return nil, err
512512 }
513513
514514 func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
@@ -528,11 +528,11 @@ func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
528528 }
529529
530530 if root, ok := rootByHost[u.Host]; ok {
531- path := strings.TrimPrefix(u.Path, "/")
532- if path == "" {
531+ assetPath := strings.TrimPrefix(u.Path, "/")
532+ if assetPath == "" {
533533 return root.assetDir, root.file, true
534534 }
535- return root.assetDir, path, true
535+ return root.assetDir, assetPath, true
536536 }
537537
538538 switch u.Opaque {
@@ -543,13 +543,13 @@ func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
543543 }
544544 }
545545
546-func shouldAddCORSHeaders(path string) bool {
547- path = strings.TrimSpace(path)
548- return strings.HasSuffix(path, ".wasm")
546+func shouldAddCORSHeaders(requestPath string) bool {
547+ requestPath = strings.TrimSpace(requestPath)
548+ return strings.HasSuffix(requestPath, ".wasm")
549549 }
550550
551-func responseHeadersForPath(path, contentType string) map[string]string {
552- if !shouldAddCORSHeaders(path) {
551+func responseHeadersForPath(requestPath, contentType string) map[string]string {
552+ if !shouldAddCORSHeaders(requestPath) {
553553 return nil
554554 }
555555
@@ -238,11 +238,11 @@ func (h *DumbSchemeHandler) handleFaviconAPI(req *SchemeRequest) *SchemeResponse
238 if !service.HasPNGSizedOnDisk(domain, size) {238 if !service.HasPNGSizedOnDisk(domain, size) {
239 return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached")239 return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached")
240 }240 }
241- path := service.DiskPathPNGSized(domain, size)241+ diskPath := service.DiskPathPNGSized(domain, size)
242- if path == "" {242+ if diskPath == "" {
243 return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached")243 return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached")
244 }244 }
245- data, err := os.ReadFile(path)245+ data, err := os.ReadFile(diskPath)
246 if err != nil || len(data) == 0 {246 if err != nil || len(data) == 0 {
247 return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached")247 return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached")
248 }248 }
@@ -330,11 +330,11 @@ func buildConfigResponse(build func() ([]byte, error)) *SchemeResponse {
330 }330 }
331 331
332 // RegisterPage registers a handler for a specific path.332 // RegisterPage registers a handler for a specific path.
333-func (h *DumbSchemeHandler) RegisterPage(path string, handler PageHandler) {333+func (h *DumbSchemeHandler) RegisterPage(pagePath string, handler PageHandler) {
334 h.mu.Lock()334 h.mu.Lock()
335 defer h.mu.Unlock()335 defer h.mu.Unlock()
336- h.handlers[path] = handler336+ h.handlers[pagePath] = handler
337- h.logger.Debug().Str("path", path).Msg("registered page handler")337+ h.logger.Debug().Str("path", pagePath).Msg("registered page handler")
338 }338 }
339 339
340 // HandleRequest processes a scheme request and sends the response.340 // HandleRequest processes a scheme request and sends the response.
@@ -443,7 +443,7 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
443 return nil443 return nil
444 }444 }
445 445
446- data, headers, err := readAssetWithEncoding(h.assets, fullPath, relPath)446+ data, err := readAssetWithEncoding(h.assets, fullPath, relPath)
447 if err != nil {447 if err != nil {
448 h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")448 h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")
449 return nil449 return nil
@@ -460,7 +460,7 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
460 Data: data,460 Data: data,
461 ContentType: contentType,461 ContentType: contentType,
462 StatusCode: http.StatusOK,462 StatusCode: http.StatusOK,
463- Headers: headers,463+ Headers: nil,
464 }464 }
465 }465 }
466 466
@@ -487,7 +487,7 @@ func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath
487 return fullPath, cleanRelPath, true487 return fullPath, cleanRelPath, true
488 }488 }
489 489
490-func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, map[string]string, error) {490+func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, error) {
491 var compressedErr error491 var compressedErr error
492 if strings.HasSuffix(relPath, ".wasm") {492 if strings.HasSuffix(relPath, ".wasm") {
493 if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil {493 if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil {
@@ -497,18 +497,18 @@ func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, m
497 } else if len(data) > maxSystemviewsWASMBytes {497 } else if len(data) > maxSystemviewsWASMBytes {
498 compressedErr = fmt.Errorf("decompressed asset %s exceeds %d bytes", fullPath, maxSystemviewsWASMBytes)498 compressedErr = fmt.Errorf("decompressed asset %s exceeds %d bytes", fullPath, maxSystemviewsWASMBytes)
499 } else {499 } else {
500- return data, nil, nil500+ return data, nil
501 }501 }
502 }502 }
503 }503 }
504 data, err := fs.ReadFile(assets, fullPath)504 data, err := fs.ReadFile(assets, fullPath)
505 if err == nil {505 if err == nil {
506- return data, nil, nil506+ return data, nil
507 }507 }
508 if compressedErr != nil {508 if compressedErr != nil {
509- return nil, nil, compressedErr509+ return nil, compressedErr
510 }510 }
511- return nil, nil, err511+ return nil, err
512 }512 }
513 513
514 func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {514 func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
@@ -528,11 +528,11 @@ func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
528 }528 }
529 529
530 if root, ok := rootByHost[u.Host]; ok {530 if root, ok := rootByHost[u.Host]; ok {
531- path := strings.TrimPrefix(u.Path, "/")531+ assetPath := strings.TrimPrefix(u.Path, "/")
532- if path == "" {532+ if assetPath == "" {
533 return root.assetDir, root.file, true533 return root.assetDir, root.file, true
534 }534 }
535- return root.assetDir, path, true535+ return root.assetDir, assetPath, true
536 }536 }
537 537
538 switch u.Opaque {538 switch u.Opaque {
@@ -543,13 +543,13 @@ func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
543 }543 }
544 }544 }
545 545
546-func shouldAddCORSHeaders(path string) bool {546+func shouldAddCORSHeaders(requestPath string) bool {
547- path = strings.TrimSpace(path)547+ requestPath = strings.TrimSpace(requestPath)
548- return strings.HasSuffix(path, ".wasm")548+ return strings.HasSuffix(requestPath, ".wasm")
549 }549 }
550 550
551-func responseHeadersForPath(path, contentType string) map[string]string {551+func responseHeadersForPath(requestPath, contentType string) map[string]string {
552- if !shouldAddCORSHeaders(path) {552+ if !shouldAddCORSHeaders(requestPath) {
553 return nil553 return nil
554 }554 }
555 555
M internal/infrastructure/webkit/scheme_handler_test.go +1 -2
@@ -276,11 +276,10 @@ func TestResponseHeadersForPath_WASMIncludesContentTypeAndCORS(t *testing.T) {
276276 func TestReadAssetWithEncodingDecompressesEmbeddedBrotliWASM(t *testing.T) {
277277 t.Parallel()
278278
279- data, headers, err := readAssetWithEncoding(assets.WebUIAssets, "systemviews/systemviews.wasm", "systemviews.wasm")
279+ data, err := readAssetWithEncoding(assets.WebUIAssets, "systemviews/systemviews.wasm", "systemviews.wasm")
280280 require.NoError(t, err)
281281 require.GreaterOrEqual(t, len(data), 4)
282282 assert.Equal(t, "\x00asm", string(data[:4]))
283- assert.NotContains(t, headers, "Content-Encoding")
284283 }
285284
286285 func TestHandleAssetRejectsTraversalOutsideSystemviews(t *testing.T) {
@@ -276,11 +276,10 @@ func TestResponseHeadersForPath_WASMIncludesContentTypeAndCORS(t *testing.T) {
276 func TestReadAssetWithEncodingDecompressesEmbeddedBrotliWASM(t *testing.T) {276 func TestReadAssetWithEncodingDecompressesEmbeddedBrotliWASM(t *testing.T) {
277 t.Parallel()277 t.Parallel()
278 278
279- data, headers, err := readAssetWithEncoding(assets.WebUIAssets, "systemviews/systemviews.wasm", "systemviews.wasm")279+ data, err := readAssetWithEncoding(assets.WebUIAssets, "systemviews/systemviews.wasm", "systemviews.wasm")
280 require.NoError(t, err)280 require.NoError(t, err)
281 require.GreaterOrEqual(t, len(data), 4)281 require.GreaterOrEqual(t, len(data), 4)
282 assert.Equal(t, "\x00asm", string(data[:4]))282 assert.Equal(t, "\x00asm", string(data[:4]))
283- assert.NotContains(t, headers, "Content-Encoding")
284 }283 }
285 284
286 func TestHandleAssetRejectsTraversalOutsideSystemviews(t *testing.T) {285 func TestHandleAssetRejectsTraversalOutsideSystemviews(t *testing.T) {
M internal/ui/systemviews/app.go +7 -20
@@ -549,6 +549,7 @@ type historyRouteSnapshot struct {
549549 domainFilter string
550550 offset int
551551 windowBefore time.Time
552+ windowAfter time.Time
552553 notice string
553554 error string
554555 }
@@ -572,6 +573,7 @@ func (a *App) currentHistoryRouteSnapshotLocked() historyRouteSnapshot {
572573 domainFilter: a.historyDomainFilter,
573574 offset: a.historyOffset,
574575 windowBefore: a.historyWindowBefore,
576+ windowAfter: a.historyWindowAfter,
575577 notice: a.historyNotice,
576578 error: a.historyError,
577579 }
@@ -602,9 +604,11 @@ func (a *App) renderHistoryRouteSnapshot(ctx context.Context, snapshot historyRo
602604 domains = nil
603605 }
604606 result := historyRouteResult{
605- entries: entries,
606- stats: stats,
607- domains: domains,
607+ entries: entries,
608+ stats: stats,
609+ domains: domains,
610+ windowBefore: snapshot.windowBefore,
611+ windowAfter: snapshot.windowAfter,
608612 }
609613 if window != nil {
610614 result.windowBefore = window.Before
@@ -666,23 +670,6 @@ func (a *App) commitHistoryRouteResultLocked(result historyRouteResult) {
666670 a.renderedHTML = result.html
667671 }
668672
669-func (a *App) loadHistoryWindow(ctx context.Context) (*entity.HistoryWindow, []*entity.HistoryEntry, error) {
670- query := strings.TrimSpace(a.historyQuery)
671- domain := strings.TrimSpace(a.historyDomainFilter)
672- if query != "" {
673- entries, err := a.deps.History.Search(ctx, query, historySearchLimit)
674- return nil, entries, err
675- }
676- window, err := a.deps.History.TimelineWindow(ctx, a.historyWindowBefore, domain)
677- if err != nil {
678- return nil, nil, err
679- }
680- if window == nil {
681- return nil, nil, nil
682- }
683- return window, window.Entries, nil
684-}
685-
686673 func (a *App) loadFavoritesRoute(ctx context.Context) error {
687674 if a.deps.Favorites == nil {
688675 a.resetRouteState()
@@ -549,6 +549,7 @@ type historyRouteSnapshot struct {
549 domainFilter string549 domainFilter string
550 offset int550 offset int
551 windowBefore time.Time551 windowBefore time.Time
552+ windowAfter time.Time
552 notice string553 notice string
553 error string554 error string
554 }555 }
@@ -572,6 +573,7 @@ func (a *App) currentHistoryRouteSnapshotLocked() historyRouteSnapshot {
572 domainFilter: a.historyDomainFilter,573 domainFilter: a.historyDomainFilter,
573 offset: a.historyOffset,574 offset: a.historyOffset,
574 windowBefore: a.historyWindowBefore,575 windowBefore: a.historyWindowBefore,
576+ windowAfter: a.historyWindowAfter,
575 notice: a.historyNotice,577 notice: a.historyNotice,
576 error: a.historyError,578 error: a.historyError,
577 }579 }
@@ -602,9 +604,11 @@ func (a *App) renderHistoryRouteSnapshot(ctx context.Context, snapshot historyRo
602 domains = nil604 domains = nil
603 }605 }
604 result := historyRouteResult{606 result := historyRouteResult{
605- entries: entries,607+ entries: entries,
606- stats: stats,608+ stats: stats,
607- domains: domains,609+ domains: domains,
610+ windowBefore: snapshot.windowBefore,
611+ windowAfter: snapshot.windowAfter,
608 }612 }
609 if window != nil {613 if window != nil {
610 result.windowBefore = window.Before614 result.windowBefore = window.Before
@@ -666,23 +670,6 @@ func (a *App) commitHistoryRouteResultLocked(result historyRouteResult) {
666 a.renderedHTML = result.html670 a.renderedHTML = result.html
667 }671 }
668 672
669-func (a *App) loadHistoryWindow(ctx context.Context) (*entity.HistoryWindow, []*entity.HistoryEntry, error) {
670- query := strings.TrimSpace(a.historyQuery)
671- domain := strings.TrimSpace(a.historyDomainFilter)
672- if query != "" {
673- entries, err := a.deps.History.Search(ctx, query, historySearchLimit)
674- return nil, entries, err
675- }
676- window, err := a.deps.History.TimelineWindow(ctx, a.historyWindowBefore, domain)
677- if err != nil {
678- return nil, nil, err
679- }
680- if window == nil {
681- return nil, nil, nil
682- }
683- return window, window.Entries, nil
684-}
685-
686 func (a *App) loadFavoritesRoute(ctx context.Context) error {673 func (a *App) loadFavoritesRoute(ctx context.Context) error {
687 if a.deps.Favorites == nil {674 if a.deps.Favorites == nil {
688 a.resetRouteState()675 a.resetRouteState()
M internal/ui/systemviews/app_test.go +25 -3
@@ -198,6 +198,16 @@ func TestSurfaceActionErrorWhileAsyncHydrationBlockedPreventsStaleMount(t *testi
198198 assert.NotContains(t, dom.html, "Stale hydrated entry")
199199 }
200200
201+func TestCurrentHistoryRouteSnapshotIncludesWindowAfter(t *testing.T) {
202+ cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC)
203+ app := NewApp(Dependencies{History: &fakeHistoryService{}, LocationURI: "dumb://history"})
204+ app.currentRoute = RouteHistory
205+ app.historyWindowAfter = cursor
206+
207+ snapshot := app.currentHistoryRouteSnapshotLocked()
208+ assert.Equal(t, cursor, snapshot.windowAfter)
209+}
210+
201211 func TestAppHandleHistoryLoadMoreAppendsOlderWindow(t *testing.T) {
202212 t.Parallel()
203213
@@ -792,7 +802,7 @@ func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) {
792802 }))
793803 assert.True(t, service.calledSave)
794804 assert.Equal(t, 18, service.savedConfig.Appearance.DefaultFontSize)
795- assert.Equal(t, 1.25, service.savedConfig.DefaultUIScale)
805+ assert.InEpsilon(t, 1.25, service.savedConfig.DefaultUIScale, 0.001)
796806 assert.Contains(t, dom.html, "Saved appearance settings")
797807 assert.Contains(t, dom.html, `class="sv-app sv-light"`)
798808
@@ -1287,7 +1297,7 @@ func TestHandleFavoriteCreateAcceptsInternalDumbRoutes(t *testing.T) {
12871297 Action: favoriteActionCreate,
12881298 Data: map[string]string{"url": "dumb:history", "title": "History"},
12891299 }))
1290- assert.Equal(t, "dumb://history", favorites.createdFavorite.URL)
1300+ assert.Equal(t, "dumb:history", favorites.createdFavorite.URL)
12911301
12921302 require.NoError(t, app.handleFavoriteAction(context.Background(), DOMAction{
12931303 Action: favoriteActionCreate,
@@ -1296,6 +1306,17 @@ func TestHandleFavoriteCreateAcceptsInternalDumbRoutes(t *testing.T) {
12961306 assert.Equal(t, "dumb://config", favorites.createdFavorite.URL)
12971307 }
12981308
1309+func TestParsePositiveInt64DistinguishesParseAndRangeErrors(t *testing.T) {
1310+ _, err := parsePositiveInt64("abc", "tag id")
1311+ require.Error(t, err)
1312+ assert.Contains(t, err.Error(), "invalid tag id")
1313+ assert.Contains(t, err.Error(), "invalid syntax")
1314+
1315+ _, err = parsePositiveInt64("0", "tag id")
1316+ require.Error(t, err)
1317+ assert.Contains(t, err.Error(), "tag id must be a positive integer")
1318+}
1319+
12991320 func TestHandleFavoriteTagActionsAcceptSnakeCaseIDs(t *testing.T) {
13001321 favorites := &fakeFavoritesService{}
13011322 app := NewApp(Dependencies{Favorites: favorites})
@@ -1335,7 +1356,8 @@ func TestHandleHistorySearchClearsDomainFilter(t *testing.T) {
13351356 func TestAppRunWithContextRejectsNilContext(t *testing.T) {
13361357 app := NewApp(Dependencies{DOM: &fakeDOM{}, LocationURI: "dumb://history"})
13371358
1338- err := app.RunWithContext(nil)
1359+ var ctx context.Context
1360+ err := app.RunWithContext(ctx)
13391361
13401362 require.ErrorContains(t, err, "context is nil")
13411363 }
@@ -198,6 +198,16 @@ func TestSurfaceActionErrorWhileAsyncHydrationBlockedPreventsStaleMount(t *testi
198 assert.NotContains(t, dom.html, "Stale hydrated entry")198 assert.NotContains(t, dom.html, "Stale hydrated entry")
199 }199 }
200 200
201+func TestCurrentHistoryRouteSnapshotIncludesWindowAfter(t *testing.T) {
202+ cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC)
203+ app := NewApp(Dependencies{History: &fakeHistoryService{}, LocationURI: "dumb://history"})
204+ app.currentRoute = RouteHistory
205+ app.historyWindowAfter = cursor
206+
207+ snapshot := app.currentHistoryRouteSnapshotLocked()
208+ assert.Equal(t, cursor, snapshot.windowAfter)
209+}
210+
201 func TestAppHandleHistoryLoadMoreAppendsOlderWindow(t *testing.T) {211 func TestAppHandleHistoryLoadMoreAppendsOlderWindow(t *testing.T) {
202 t.Parallel()212 t.Parallel()
203 213
@@ -792,7 +802,7 @@ func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) {
792 }))802 }))
793 assert.True(t, service.calledSave)803 assert.True(t, service.calledSave)
794 assert.Equal(t, 18, service.savedConfig.Appearance.DefaultFontSize)804 assert.Equal(t, 18, service.savedConfig.Appearance.DefaultFontSize)
795- assert.Equal(t, 1.25, service.savedConfig.DefaultUIScale)805+ assert.InEpsilon(t, 1.25, service.savedConfig.DefaultUIScale, 0.001)
796 assert.Contains(t, dom.html, "Saved appearance settings")806 assert.Contains(t, dom.html, "Saved appearance settings")
797 assert.Contains(t, dom.html, `class="sv-app sv-light"`)807 assert.Contains(t, dom.html, `class="sv-app sv-light"`)
798 808
@@ -1287,7 +1297,7 @@ func TestHandleFavoriteCreateAcceptsInternalDumbRoutes(t *testing.T) {
1287 Action: favoriteActionCreate,1297 Action: favoriteActionCreate,
1288 Data: map[string]string{"url": "dumb:history", "title": "History"},1298 Data: map[string]string{"url": "dumb:history", "title": "History"},
1289 }))1299 }))
1290- assert.Equal(t, "dumb://history", favorites.createdFavorite.URL)1300+ assert.Equal(t, "dumb:history", favorites.createdFavorite.URL)
1291 1301
1292 require.NoError(t, app.handleFavoriteAction(context.Background(), DOMAction{1302 require.NoError(t, app.handleFavoriteAction(context.Background(), DOMAction{
1293 Action: favoriteActionCreate,1303 Action: favoriteActionCreate,
@@ -1296,6 +1306,17 @@ func TestHandleFavoriteCreateAcceptsInternalDumbRoutes(t *testing.T) {
1296 assert.Equal(t, "dumb://config", favorites.createdFavorite.URL)1306 assert.Equal(t, "dumb://config", favorites.createdFavorite.URL)
1297 }1307 }
1298 1308
1309+func TestParsePositiveInt64DistinguishesParseAndRangeErrors(t *testing.T) {
1310+ _, err := parsePositiveInt64("abc", "tag id")
1311+ require.Error(t, err)
1312+ assert.Contains(t, err.Error(), "invalid tag id")
1313+ assert.Contains(t, err.Error(), "invalid syntax")
1314+
1315+ _, err = parsePositiveInt64("0", "tag id")
1316+ require.Error(t, err)
1317+ assert.Contains(t, err.Error(), "tag id must be a positive integer")
1318+}
1319+
1299 func TestHandleFavoriteTagActionsAcceptSnakeCaseIDs(t *testing.T) {1320 func TestHandleFavoriteTagActionsAcceptSnakeCaseIDs(t *testing.T) {
1300 favorites := &fakeFavoritesService{}1321 favorites := &fakeFavoritesService{}
1301 app := NewApp(Dependencies{Favorites: favorites})1322 app := NewApp(Dependencies{Favorites: favorites})
@@ -1335,7 +1356,8 @@ func TestHandleHistorySearchClearsDomainFilter(t *testing.T) {
1335 func TestAppRunWithContextRejectsNilContext(t *testing.T) {1356 func TestAppRunWithContextRejectsNilContext(t *testing.T) {
1336 app := NewApp(Dependencies{DOM: &fakeDOM{}, LocationURI: "dumb://history"})1357 app := NewApp(Dependencies{DOM: &fakeDOM{}, LocationURI: "dumb://history"})
1337 1358
1338- err := app.RunWithContext(nil)1359+ var ctx context.Context
1360+ err := app.RunWithContext(ctx)
1339 1361
1340 require.ErrorContains(t, err, "context is nil")1362 require.ErrorContains(t, err, "context is nil")
1341 }1363 }
M internal/ui/systemviews/config.templ +1 -0
@@ -105,6 +105,7 @@ templ PerformanceSettings(config dto.SystemviewConfigPayload) {
105105 <form class="sv-card-form sv-config-form" data-sv-action="config.performance.save" data-sv-performance-form>
106106 <label><span>Profile</span><select name="profile" data-sv-performance-profile>
107107 <option value="default" selected?={ performanceProfileSelected(config.Performance.Profile, "default") }>Default</option>
108+ <option value="balanced" selected?={ performanceProfileSelected(config.Performance.Profile, "balanced") }>Balanced</option>
108109 <option value="lite" selected?={ performanceProfileSelected(config.Performance.Profile, "lite") }>Lite</option>
109110 <option value="max" selected?={ performanceProfileSelected(config.Performance.Profile, "max") }>Max</option>
110111 <option value="custom" selected?={ performanceProfileSelected(config.Performance.Profile, "custom") }>Custom</option>
@@ -105,6 +105,7 @@ templ PerformanceSettings(config dto.SystemviewConfigPayload) {
105 <form class="sv-card-form sv-config-form" data-sv-action="config.performance.save" data-sv-performance-form>105 <form class="sv-card-form sv-config-form" data-sv-action="config.performance.save" data-sv-performance-form>
106 <label><span>Profile</span><select name="profile" data-sv-performance-profile>106 <label><span>Profile</span><select name="profile" data-sv-performance-profile>
107 <option value="default" selected?={ performanceProfileSelected(config.Performance.Profile, "default") }>Default</option>107 <option value="default" selected?={ performanceProfileSelected(config.Performance.Profile, "default") }>Default</option>
108+ <option value="balanced" selected?={ performanceProfileSelected(config.Performance.Profile, "balanced") }>Balanced</option>
108 <option value="lite" selected?={ performanceProfileSelected(config.Performance.Profile, "lite") }>Lite</option>109 <option value="lite" selected?={ performanceProfileSelected(config.Performance.Profile, "lite") }>Lite</option>
109 <option value="max" selected?={ performanceProfileSelected(config.Performance.Profile, "max") }>Max</option>110 <option value="max" selected?={ performanceProfileSelected(config.Performance.Profile, "max") }>Max</option>
110 <option value="custom" selected?={ performanceProfileSelected(config.Performance.Profile, "custom") }>Custom</option>111 <option value="custom" selected?={ performanceProfileSelected(config.Performance.Profile, "custom") }>Custom</option>
M internal/ui/systemviews/config_actions.go +8 -5
@@ -25,6 +25,9 @@ const (
2525 configActionResetKeybinding = "config.keybinding.reset"
2626 configActionResetAllKeybindings = "config.keybinding.resetAll"
2727 configRequestIDPrefix = "systemviews-config"
28+ balancedPerformanceProfile = "balanced"
29+ customPerformanceProfile = "custom"
30+ httpScheme = "http"
2831 maxCustomPerformanceThreads = 8
2932 maxCustomPerformanceWebMemoryMB = 16384
3033 maxCustomPerformanceNetworkMemoryMB = 4096
@@ -241,9 +244,9 @@ func (a *App) savePerformanceConfig(ctx context.Context, data map[string]string)
241244 return fmt.Errorf("unknown performance profile: %q", profile)
242245 }
243246 cfg.Performance.Profile = profile
244- if profile != "custom" {
245- if err := a.saveEditableConfig(ctx, cfg); err != nil {
246- return err
247+ if profile != customPerformanceProfile {
248+ if saveErr := a.saveEditableConfig(ctx, cfg); saveErr != nil {
249+ return saveErr
247250 }
248251 a.configNotice = "Saved performance settings. Restart may be required."
249252 return nil
@@ -479,7 +482,7 @@ func requireSearchURLTemplate(raw, label string) (string, error) {
479482 return "", fmt.Errorf("%s must include %%s placeholder", label)
480483 }
481484 parsed, err := url.Parse(strings.Replace(value, "%s", "example", 1))
482- if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
485+ if err != nil || parsed.Host == "" || (parsed.Scheme != httpScheme && parsed.Scheme != "https") {
483486 return "", fmt.Errorf("%s must be an absolute http(s) URL", label)
484487 }
485488 return value, nil
@@ -503,7 +506,7 @@ func parseConfigFloat(raw, label string) (float64, error) {
503506
504507 func isAllowedPerformanceProfile(profile string) bool {
505508 switch profile {
506- case "default", "lite", "max", "custom":
509+ case "default", balancedPerformanceProfile, "lite", "max", customPerformanceProfile:
507510 return true
508511 default:
509512 return false
@@ -25,6 +25,9 @@ const (
25 configActionResetKeybinding = "config.keybinding.reset"25 configActionResetKeybinding = "config.keybinding.reset"
26 configActionResetAllKeybindings = "config.keybinding.resetAll"26 configActionResetAllKeybindings = "config.keybinding.resetAll"
27 configRequestIDPrefix = "systemviews-config"27 configRequestIDPrefix = "systemviews-config"
28+ balancedPerformanceProfile = "balanced"
29+ customPerformanceProfile = "custom"
30+ httpScheme = "http"
28 maxCustomPerformanceThreads = 831 maxCustomPerformanceThreads = 8
29 maxCustomPerformanceWebMemoryMB = 1638432 maxCustomPerformanceWebMemoryMB = 16384
30 maxCustomPerformanceNetworkMemoryMB = 409633 maxCustomPerformanceNetworkMemoryMB = 4096
@@ -241,9 +244,9 @@ func (a *App) savePerformanceConfig(ctx context.Context, data map[string]string)
241 return fmt.Errorf("unknown performance profile: %q", profile)244 return fmt.Errorf("unknown performance profile: %q", profile)
242 }245 }
243 cfg.Performance.Profile = profile246 cfg.Performance.Profile = profile
244- if profile != "custom" {247+ if profile != customPerformanceProfile {
245- if err := a.saveEditableConfig(ctx, cfg); err != nil {248+ if saveErr := a.saveEditableConfig(ctx, cfg); saveErr != nil {
246- return err249+ return saveErr
247 }250 }
248 a.configNotice = "Saved performance settings. Restart may be required."251 a.configNotice = "Saved performance settings. Restart may be required."
249 return nil252 return nil
@@ -479,7 +482,7 @@ func requireSearchURLTemplate(raw, label string) (string, error) {
479 return "", fmt.Errorf("%s must include %%s placeholder", label)482 return "", fmt.Errorf("%s must include %%s placeholder", label)
480 }483 }
481 parsed, err := url.Parse(strings.Replace(value, "%s", "example", 1))484 parsed, err := url.Parse(strings.Replace(value, "%s", "example", 1))
482- if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {485+ if err != nil || parsed.Host == "" || (parsed.Scheme != httpScheme && parsed.Scheme != "https") {
483 return "", fmt.Errorf("%s must be an absolute http(s) URL", label)486 return "", fmt.Errorf("%s must be an absolute http(s) URL", label)
484 }487 }
485 return value, nil488 return value, nil
@@ -503,7 +506,7 @@ func parseConfigFloat(raw, label string) (float64, error) {
503 506
504 func isAllowedPerformanceProfile(profile string) bool {507 func isAllowedPerformanceProfile(profile string) bool {
505 switch profile {508 switch profile {
506- case "default", "lite", "max", "custom":509+ case "default", balancedPerformanceProfile, "lite", "max", customPerformanceProfile:
507 return true510 return true
508 default:511 default:
509 return false512 return false
M internal/ui/systemviews/config_templ.go +82 -72 generated file: collapsed
M internal/ui/systemviews/config_view.go +55 -44
@@ -176,57 +176,68 @@ func flattenValue(rows *[]kvPair, path string, value reflect.Value, depth int) {
176176
177177 switch value.Kind() {
178178 case reflect.Struct:
179- typeOfValue := value.Type()
180- for i := 0; i < value.NumField(); i++ {
181- field := typeOfValue.Field(i)
182- if !field.IsExported() {
183- continue
184- }
185- name := jsonFieldName(field)
186- if name == "-" {
187- continue
188- }
189- nextPath := name
190- if path != "" {
191- nextPath = path + "." + name
192- }
193- flattenValue(rows, nextPath, value.Field(i), depth+1)
194- }
179+ flattenStructValue(rows, path, value, depth)
195180 case reflect.Map:
196- if value.Len() == 0 {
197- appendFlatValue(rows, path, "{}")
198- return
199- }
200-
201- keys := value.MapKeys()
202- sort.Slice(keys, func(i, j int) bool {
203- return mapKeyString(keys[i]) < mapKeyString(keys[j])
204- })
181+ flattenMapValue(rows, path, value, depth)
182+ case reflect.Slice, reflect.Array:
183+ flattenListValue(rows, path, value, depth)
184+ default:
185+ appendFlatValue(rows, path, formatFlatValue(value))
186+ }
187+}
205188
206- for _, key := range keys {
207- name := mapKeyString(key)
208- nextPath := name
209- if path != "" {
210- nextPath = path + "." + name
211- }
212- flattenValue(rows, nextPath, value.MapIndex(key), depth+1)
189+func flattenStructValue(rows *[]kvPair, path string, value reflect.Value, depth int) {
190+ typeOfValue := value.Type()
191+ for i := 0; i < value.NumField(); i++ {
192+ field := typeOfValue.Field(i)
193+ if !field.IsExported() {
194+ continue
213195 }
214- case reflect.Slice, reflect.Array:
215- if value.Len() == 0 {
216- appendFlatValue(rows, path, "[]")
217- return
196+ name := jsonFieldName(field)
197+ if name == "-" {
198+ continue
218199 }
200+ flattenValue(rows, childFlatPath(path, name), value.Field(i), depth+1)
201+ }
202+}
203+
204+func flattenMapValue(rows *[]kvPair, path string, value reflect.Value, depth int) {
205+ if value.Len() == 0 {
206+ appendFlatValue(rows, path, "{}")
207+ return
208+ }
209+
210+ keys := value.MapKeys()
211+ sort.Slice(keys, func(i, j int) bool {
212+ return mapKeyString(keys[i]) < mapKeyString(keys[j])
213+ })
214+
215+ for _, key := range keys {
216+ name := mapKeyString(key)
217+ flattenValue(rows, childFlatPath(path, name), value.MapIndex(key), depth+1)
218+ }
219+}
219220
220- for i := 0; i < value.Len(); i++ {
221- nextPath := fmt.Sprintf("%s[%d]", path, i)
222- if path == "" {
223- nextPath = fmt.Sprintf("[%d]", i)
224- }
225- flattenValue(rows, nextPath, value.Index(i), depth+1)
221+func flattenListValue(rows *[]kvPair, path string, value reflect.Value, depth int) {
222+ if value.Len() == 0 {
223+ appendFlatValue(rows, path, "[]")
224+ return
225+ }
226+
227+ for i := 0; i < value.Len(); i++ {
228+ nextPath := fmt.Sprintf("%s[%d]", path, i)
229+ if path == "" {
230+ nextPath = fmt.Sprintf("[%d]", i)
226231 }
227- default:
228- appendFlatValue(rows, path, formatFlatValue(value))
232+ flattenValue(rows, nextPath, value.Index(i), depth+1)
233+ }
234+}
235+
236+func childFlatPath(path, name string) string {
237+ if path == "" {
238+ return name
229239 }
240+ return path + "." + name
230241 }
231242
232243 func appendFlatValue(rows *[]kvPair, path, value string) {
@@ -176,57 +176,68 @@ func flattenValue(rows *[]kvPair, path string, value reflect.Value, depth int) {
176 176
177 switch value.Kind() {177 switch value.Kind() {
178 case reflect.Struct:178 case reflect.Struct:
179- typeOfValue := value.Type()179+ flattenStructValue(rows, path, value, depth)
180- for i := 0; i < value.NumField(); i++ {
181- field := typeOfValue.Field(i)
182- if !field.IsExported() {
183- continue
184- }
185- name := jsonFieldName(field)
186- if name == "-" {
187- continue
188- }
189- nextPath := name
190- if path != "" {
191- nextPath = path + "." + name
192- }
193- flattenValue(rows, nextPath, value.Field(i), depth+1)
194- }
195 case reflect.Map:180 case reflect.Map:
196- if value.Len() == 0 {181+ flattenMapValue(rows, path, value, depth)
197- appendFlatValue(rows, path, "{}")182+ case reflect.Slice, reflect.Array:
198- return183+ flattenListValue(rows, path, value, depth)
199- }184+ default:
200-185+ appendFlatValue(rows, path, formatFlatValue(value))
201- keys := value.MapKeys()186+ }
202- sort.Slice(keys, func(i, j int) bool {187+}
203- return mapKeyString(keys[i]) < mapKeyString(keys[j])
204- })
205 188
206- for _, key := range keys {189+func flattenStructValue(rows *[]kvPair, path string, value reflect.Value, depth int) {
207- name := mapKeyString(key)190+ typeOfValue := value.Type()
208- nextPath := name191+ for i := 0; i < value.NumField(); i++ {
209- if path != "" {192+ field := typeOfValue.Field(i)
210- nextPath = path + "." + name193+ if !field.IsExported() {
211- }194+ continue
212- flattenValue(rows, nextPath, value.MapIndex(key), depth+1)
213 }195 }
214- case reflect.Slice, reflect.Array:196+ name := jsonFieldName(field)
215- if value.Len() == 0 {197+ if name == "-" {
216- appendFlatValue(rows, path, "[]")198+ continue
217- return
218 }199 }
200+ flattenValue(rows, childFlatPath(path, name), value.Field(i), depth+1)
201+ }
202+}
203+
204+func flattenMapValue(rows *[]kvPair, path string, value reflect.Value, depth int) {
205+ if value.Len() == 0 {
206+ appendFlatValue(rows, path, "{}")
207+ return
208+ }
209+
210+ keys := value.MapKeys()
211+ sort.Slice(keys, func(i, j int) bool {
212+ return mapKeyString(keys[i]) < mapKeyString(keys[j])
213+ })
214+
215+ for _, key := range keys {
216+ name := mapKeyString(key)
217+ flattenValue(rows, childFlatPath(path, name), value.MapIndex(key), depth+1)
218+ }
219+}
219 220
220- for i := 0; i < value.Len(); i++ {221+func flattenListValue(rows *[]kvPair, path string, value reflect.Value, depth int) {
221- nextPath := fmt.Sprintf("%s[%d]", path, i)222+ if value.Len() == 0 {
222- if path == "" {223+ appendFlatValue(rows, path, "[]")
223- nextPath = fmt.Sprintf("[%d]", i)224+ return
224- }225+ }
225- flattenValue(rows, nextPath, value.Index(i), depth+1)226+
227+ for i := 0; i < value.Len(); i++ {
228+ nextPath := fmt.Sprintf("%s[%d]", path, i)
229+ if path == "" {
230+ nextPath = fmt.Sprintf("[%d]", i)
226 }231 }
227- default:232+ flattenValue(rows, nextPath, value.Index(i), depth+1)
228- appendFlatValue(rows, path, formatFlatValue(value))233+ }
234+}
235+
236+func childFlatPath(path, name string) string {
237+ if path == "" {
238+ return name
229 }239 }
240+ return path + "." + name
230 }241 }
231 242
232 func appendFlatValue(rows *[]kvPair, path, value string) {243 func appendFlatValue(rows *[]kvPair, path, value string) {
M internal/ui/systemviews/dom_js.go +16 -0
@@ -42,6 +42,7 @@ func (d *browserDOM) Mount(markup string) error {
4242 d.target.Set("innerHTML", markup)
4343 d.updateDocumentTitle()
4444 d.scheduleAlertDismissal()
45+ d.syncPerformanceCustomInputStates()
4546 d.setupHistoryInfiniteScroll()
4647 return nil
4748 }
@@ -286,6 +287,11 @@ func (d *browserDOM) setupHistoryInfiniteScroll() {
286287 if d == nil || !d.target.Truthy() {
287288 return
288289 }
290+ // setupHistoryInfiniteScroll callbacks close over the current load-more
291+ // button. disconnectHistoryObserver owns the cleanup chain: it calls
292+ // d.historyObserver.disconnect(), releases d.historyObserverCallback, and
293+ // lets setupHistoryInfiniteScroll overwrite d.historyObserver with the new
294+ // observer so old button closures are not retained.
289295 d.disconnectHistoryObserver()
290296 observerCtor := js.Global().Get("IntersectionObserver")
291297 if !observerCtor.Truthy() {
@@ -390,6 +396,16 @@ func collectActionData(element js.Value) map[string]string {
390396 return data
391397 }
392398
399+func (d *browserDOM) syncPerformanceCustomInputStates() {
400+ if d == nil || !d.target.Truthy() || !d.target.Get("querySelectorAll").Truthy() {
401+ return
402+ }
403+ forms := d.target.Call("querySelectorAll", "[data-sv-performance-form]")
404+ for i := 0; i < forms.Get("length").Int(); i++ {
405+ updatePerformanceCustomInputs(forms.Index(i))
406+ }
407+}
408+
393409 func updatePerformanceCustomInputs(form js.Value) {
394410 if !form.Truthy() || !form.Get("querySelector").Truthy() {
395411 return
@@ -42,6 +42,7 @@ func (d *browserDOM) Mount(markup string) error {
42 d.target.Set("innerHTML", markup)42 d.target.Set("innerHTML", markup)
43 d.updateDocumentTitle()43 d.updateDocumentTitle()
44 d.scheduleAlertDismissal()44 d.scheduleAlertDismissal()
45+ d.syncPerformanceCustomInputStates()
45 d.setupHistoryInfiniteScroll()46 d.setupHistoryInfiniteScroll()
46 return nil47 return nil
47 }48 }
@@ -286,6 +287,11 @@ func (d *browserDOM) setupHistoryInfiniteScroll() {
286 if d == nil || !d.target.Truthy() {287 if d == nil || !d.target.Truthy() {
287 return288 return
288 }289 }
290+ // setupHistoryInfiniteScroll callbacks close over the current load-more
291+ // button. disconnectHistoryObserver owns the cleanup chain: it calls
292+ // d.historyObserver.disconnect(), releases d.historyObserverCallback, and
293+ // lets setupHistoryInfiniteScroll overwrite d.historyObserver with the new
294+ // observer so old button closures are not retained.
289 d.disconnectHistoryObserver()295 d.disconnectHistoryObserver()
290 observerCtor := js.Global().Get("IntersectionObserver")296 observerCtor := js.Global().Get("IntersectionObserver")
291 if !observerCtor.Truthy() {297 if !observerCtor.Truthy() {
@@ -390,6 +396,16 @@ func collectActionData(element js.Value) map[string]string {
390 return data396 return data
391 }397 }
392 398
399+func (d *browserDOM) syncPerformanceCustomInputStates() {
400+ if d == nil || !d.target.Truthy() || !d.target.Get("querySelectorAll").Truthy() {
401+ return
402+ }
403+ forms := d.target.Call("querySelectorAll", "[data-sv-performance-form]")
404+ for i := 0; i < forms.Get("length").Int(); i++ {
405+ updatePerformanceCustomInputs(forms.Index(i))
406+ }
407+}
408+
393 func updatePerformanceCustomInputs(form js.Value) {409 func updatePerformanceCustomInputs(form js.Value) {
394 if !form.Truthy() || !form.Get("querySelector").Truthy() {410 if !form.Truthy() || !form.Get("querySelector").Truthy() {
395 return411 return
M internal/ui/systemviews/favorite_actions.go +7 -3
@@ -28,6 +28,7 @@ const (
2828 tagActionRemove = "tag.remove"
2929 )
3030
31+//nolint:gocyclo,funlen // Mechanical dispatcher keeps action routing in one place.
3132 func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {
3233 if a.deps.Favorites == nil {
3334 return fmt.Errorf("favorites service not configured")
@@ -222,7 +223,7 @@ func validateFavoriteURL(raw string) (string, error) {
222223 return favoriteURL, nil
223224 }
224225 if parsed.Opaque != "" {
225- return "dumb://" + strings.TrimPrefix(parsed.Opaque, "//"), nil
226+ return favoriteURL, nil
226227 }
227228 return "", fmt.Errorf("favorite URL host is required")
228229 default:
@@ -242,8 +243,11 @@ func firstActionValue(data map[string]string, keys ...string) string {
242243
243244 func parsePositiveInt64(raw, label string) (int64, error) {
244245 id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
245- if err != nil || id <= 0 {
246- return 0, fmt.Errorf("invalid %s", label)
246+ if err != nil {
247+ return 0, fmt.Errorf("invalid %s: %w", label, err)
248+ }
249+ if id <= 0 {
250+ return 0, fmt.Errorf("%s must be a positive integer", label)
247251 }
248252 return id, nil
249253 }
@@ -28,6 +28,7 @@ const (
28 tagActionRemove = "tag.remove"28 tagActionRemove = "tag.remove"
29 )29 )
30 30
31+//nolint:gocyclo,funlen // Mechanical dispatcher keeps action routing in one place.
31 func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {32 func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {
32 if a.deps.Favorites == nil {33 if a.deps.Favorites == nil {
33 return fmt.Errorf("favorites service not configured")34 return fmt.Errorf("favorites service not configured")
@@ -222,7 +223,7 @@ func validateFavoriteURL(raw string) (string, error) {
222 return favoriteURL, nil223 return favoriteURL, nil
223 }224 }
224 if parsed.Opaque != "" {225 if parsed.Opaque != "" {
225- return "dumb://" + strings.TrimPrefix(parsed.Opaque, "//"), nil226+ return favoriteURL, nil
226 }227 }
227 return "", fmt.Errorf("favorite URL host is required")228 return "", fmt.Errorf("favorite URL host is required")
228 default:229 default:
@@ -242,8 +243,11 @@ func firstActionValue(data map[string]string, keys ...string) string {
242 243
243 func parsePositiveInt64(raw, label string) (int64, error) {244 func parsePositiveInt64(raw, label string) (int64, error) {
244 id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)245 id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
245- if err != nil || id <= 0 {246+ if err != nil {
246- return 0, fmt.Errorf("invalid %s", label)247+ return 0, fmt.Errorf("invalid %s: %w", label, err)
248+ }
249+ if id <= 0 {
250+ return 0, fmt.Errorf("%s must be a positive integer", label)
247 }251 }
248 return id, nil252 return id, nil
249 }253 }
M internal/ui/systemviews/favorites_view.go +5 -3
@@ -7,6 +7,8 @@ import (
77 "github.com/bnema/dumber/internal/domain/entity"
88 )
99
10+const defaultTagColor = "#808080"
11+
1012 type favoritesRenderData struct {
1113 Favorites []*entity.Favorite
1214 Folders []*entity.Folder
@@ -120,16 +122,16 @@ func favoriteTagButtonClass(favorite *entity.Favorite, tag *entity.Tag) string {
120122 func safeTagColor(raw string) string {
121123 value := strings.TrimSpace(raw)
122124 if len(value) != 4 && len(value) != 7 {
123- return "#808080"
125+ return defaultTagColor
124126 }
125127 if value[0] != '#' {
126- return "#808080"
128+ return defaultTagColor
127129 }
128130 for _, ch := range value[1:] {
129131 if (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F') {
130132 continue
131133 }
132- return "#808080"
134+ return defaultTagColor
133135 }
134136 return value
135137 }
@@ -7,6 +7,8 @@ import (
7 "github.com/bnema/dumber/internal/domain/entity"7 "github.com/bnema/dumber/internal/domain/entity"
8 )8 )
9 9
10+const defaultTagColor = "#808080"
11+
10 type favoritesRenderData struct {12 type favoritesRenderData struct {
11 Favorites []*entity.Favorite13 Favorites []*entity.Favorite
12 Folders []*entity.Folder14 Folders []*entity.Folder
@@ -120,16 +122,16 @@ func favoriteTagButtonClass(favorite *entity.Favorite, tag *entity.Tag) string {
120 func safeTagColor(raw string) string {122 func safeTagColor(raw string) string {
121 value := strings.TrimSpace(raw)123 value := strings.TrimSpace(raw)
122 if len(value) != 4 && len(value) != 7 {124 if len(value) != 4 && len(value) != 7 {
123- return "#808080"125+ return defaultTagColor
124 }126 }
125 if value[0] != '#' {127 if value[0] != '#' {
126- return "#808080"128+ return defaultTagColor
127 }129 }
128 for _, ch := range value[1:] {130 for _, ch := range value[1:] {
129 if (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F') {131 if (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F') {
130 continue132 continue
131 }133 }
132- return "#808080"134+ return defaultTagColor
133 }135 }
134 return value136 return value
135 }137 }
M internal/ui/systemviews/history_actions.go +1 -0
@@ -86,6 +86,7 @@ func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error {
8686 }
8787 }
8888
89+//nolint:gocyclo,funlen // Mechanical dispatcher keeps action routing in one place.
8990 func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error {
9091 if a.deps.History == nil {
9192 return fmt.Errorf("history service not configured")
@@ -86,6 +86,7 @@ func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error {
86 }86 }
87 }87 }
88 88
89+//nolint:gocyclo,funlen // Mechanical dispatcher keeps action routing in one place.
89 func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error {90 func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error {
90 if a.deps.History == nil {91 if a.deps.History == nil {
91 return fmt.Errorf("history service not configured")92 return fmt.Errorf("history service not configured")
M internal/ui/systemviews/history_view.go +3 -23
@@ -12,6 +12,8 @@ import (
1212
1313 const historyURLDisplayMaxRunes = 180
1414
15+const historySearchTitleMaxRunes = 48
16+
1517 type historyRenderData struct {
1618 Entries []*entity.HistoryEntry
1719 Stats *entity.HistoryStats
@@ -56,7 +58,7 @@ func historyDocumentTitle(data historyRenderData) string {
5658 return "History — Loading"
5759 }
5860 if query := strings.TrimSpace(data.Query); query != "" {
59- return "History — search: " + truncateTitle(query, 48)
61+ return "History — search: " + truncateTitle(query, historySearchTitleMaxRunes)
6062 }
6163 if domain := strings.TrimSpace(data.DomainFilter); domain != "" {
6264 label := browserurl.DisplayDomain(domain)
@@ -109,28 +111,6 @@ func historyIsPaginated(data historyRenderData) bool {
109111 return historyLimit(data) > 0
110112 }
111113
112-func previousHistoryOffset(data historyRenderData) int {
113- if !historyIsPaginated(data) {
114- return 0
115- }
116- offset := data.Offset - historyLimit(data)
117- if offset < 0 {
118- return 0
119- }
120- return offset
121-}
122-
123-func nextHistoryOffset(data historyRenderData) int {
124- if !historyIsPaginated(data) {
125- return 0
126- }
127- return max(data.Offset, 0) + historyLimit(data)
128-}
129-
130-func disableHistoryPrev(data historyRenderData) bool {
131- return !historyIsPaginated(data) || data.Offset <= 0 || strings.TrimSpace(data.Query) != ""
132-}
133-
134114 func disableHistoryNext(data historyRenderData) bool {
135115 return !historyIsPaginated(data) || strings.TrimSpace(data.Query) != "" || countHistoryEntries(data.Entries) < historyLimit(data)
136116 }
@@ -12,6 +12,8 @@ import (
12 12
13 const historyURLDisplayMaxRunes = 18013 const historyURLDisplayMaxRunes = 180
14 14
15+const historySearchTitleMaxRunes = 48
16+
15 type historyRenderData struct {17 type historyRenderData struct {
16 Entries []*entity.HistoryEntry18 Entries []*entity.HistoryEntry
17 Stats *entity.HistoryStats19 Stats *entity.HistoryStats
@@ -56,7 +58,7 @@ func historyDocumentTitle(data historyRenderData) string {
56 return "History — Loading"58 return "History — Loading"
57 }59 }
58 if query := strings.TrimSpace(data.Query); query != "" {60 if query := strings.TrimSpace(data.Query); query != "" {
59- return "History — search: " + truncateTitle(query, 48)61+ return "History — search: " + truncateTitle(query, historySearchTitleMaxRunes)
60 }62 }
61 if domain := strings.TrimSpace(data.DomainFilter); domain != "" {63 if domain := strings.TrimSpace(data.DomainFilter); domain != "" {
62 label := browserurl.DisplayDomain(domain)64 label := browserurl.DisplayDomain(domain)
@@ -109,28 +111,6 @@ func historyIsPaginated(data historyRenderData) bool {
109 return historyLimit(data) > 0111 return historyLimit(data) > 0
110 }112 }
111 113
112-func previousHistoryOffset(data historyRenderData) int {
113- if !historyIsPaginated(data) {
114- return 0
115- }
116- offset := data.Offset - historyLimit(data)
117- if offset < 0 {
118- return 0
119- }
120- return offset
121-}
122-
123-func nextHistoryOffset(data historyRenderData) int {
124- if !historyIsPaginated(data) {
125- return 0
126- }
127- return max(data.Offset, 0) + historyLimit(data)
128-}
129-
130-func disableHistoryPrev(data historyRenderData) bool {
131- return !historyIsPaginated(data) || data.Offset <= 0 || strings.TrimSpace(data.Query) != ""
132-}
133-
134 func disableHistoryNext(data historyRenderData) bool {114 func disableHistoryNext(data historyRenderData) bool {
135 return !historyIsPaginated(data) || strings.TrimSpace(data.Query) != "" || countHistoryEntries(data.Entries) < historyLimit(data)115 return !historyIsPaginated(data) || strings.TrimSpace(data.Query) != "" || countHistoryEntries(data.Entries) < historyLimit(data)
136 }116 }
M internal/ui/systemviews/href.go +3 -1
@@ -5,6 +5,8 @@ import (
55 "strings"
66 )
77
8+const httpsScheme = "https"
9+
810 func sanitizeHref(raw string) string {
911 trimmed := strings.TrimSpace(raw)
1012 if trimmed == "" || strings.HasPrefix(trimmed, "//") {
@@ -24,7 +26,7 @@ func sanitizeHref(raw string) string {
2426 }
2527
2628 switch strings.ToLower(parsed.Scheme) {
27- case "http", "https":
29+ case httpScheme, httpsScheme:
2830 if parsed.Host == "" {
2931 return "#"
3032 }
@@ -5,6 +5,8 @@ import (
5 "strings"5 "strings"
6 )6 )
7 7
8+const httpsScheme = "https"
9+
8 func sanitizeHref(raw string) string {10 func sanitizeHref(raw string) string {
9 trimmed := strings.TrimSpace(raw)11 trimmed := strings.TrimSpace(raw)
10 if trimmed == "" || strings.HasPrefix(trimmed, "//") {12 if trimmed == "" || strings.HasPrefix(trimmed, "//") {
@@ -24,7 +26,7 @@ func sanitizeHref(raw string) string {
24 }26 }
25 27
26 switch strings.ToLower(parsed.Scheme) {28 switch strings.ToLower(parsed.Scheme) {
27- case "http", "https":29+ case httpScheme, httpsScheme:
28 if parsed.Host == "" {30 if parsed.Host == "" {
29 return "#"31 return "#"
30 }32 }
M internal/ui/systemviews/shell.go +4 -9
@@ -91,18 +91,10 @@ func listHTML(rows, emptyMessage string) string {
9191 }
9292
9393 // listRowHTML inserts trusted, pre-sanitized raw HTML inside a list item.
94-func listRowHTML(inner string) string {
95- return fmt.Sprintf(`<li class="sv-list-row">%s</li>`, inner)
96-}
97-
9894 func linkHTML(href, label string) string {
9995 return fmt.Sprintf(`<a class=%q href=%q>%s</a>`, "sv-link", html.EscapeString(sanitizeHref(href)), html.EscapeString(label))
10096 }
10197
102-func metaHTML(text string) string {
103- return fmt.Sprintf(`<p class="sv-meta">%s</p>`, html.EscapeString(text))
104-}
105-
10698 func kvHTML(rows []kvPair) string {
10799 var items strings.Builder
108100 for _, row := range rows {
@@ -138,5 +130,8 @@ func emptyStateHTML(message string) string {
138130 }
139131
140132 func errorStateHTML(message string) string {
141- 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))
133+ return fmt.Sprintf(
134+ `<div class="sv-error" role="alert"><p>Could not load this system view.</p><p class="sv-meta">%s</p></div>`,
135+ html.EscapeString(message),
136+ )
142137 }
@@ -91,18 +91,10 @@ func listHTML(rows, emptyMessage string) string {
91 }91 }
92 92
93 // listRowHTML inserts trusted, pre-sanitized raw HTML inside a list item.93 // listRowHTML inserts trusted, pre-sanitized raw HTML inside a list item.
94-func listRowHTML(inner string) string {
95- return fmt.Sprintf(`<li class="sv-list-row">%s</li>`, inner)
96-}
97-
98 func linkHTML(href, label string) string {94 func linkHTML(href, label string) string {
99 return fmt.Sprintf(`<a class=%q href=%q>%s</a>`, "sv-link", html.EscapeString(sanitizeHref(href)), html.EscapeString(label))95 return fmt.Sprintf(`<a class=%q href=%q>%s</a>`, "sv-link", html.EscapeString(sanitizeHref(href)), html.EscapeString(label))
100 }96 }
101 97
102-func metaHTML(text string) string {
103- return fmt.Sprintf(`<p class="sv-meta">%s</p>`, html.EscapeString(text))
104-}
105-
106 func kvHTML(rows []kvPair) string {98 func kvHTML(rows []kvPair) string {
107 var items strings.Builder99 var items strings.Builder
108 for _, row := range rows {100 for _, row := range rows {
@@ -138,5 +130,8 @@ func emptyStateHTML(message string) string {
138 }130 }
139 131
140 func errorStateHTML(message string) string {132 func errorStateHTML(message string) string {
141- 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))133+ return fmt.Sprintf(
134+ `<div class="sv-error" role="alert"><p>Could not load this system view.</p><p class="sv-meta">%s</p></div>`,
135+ html.EscapeString(message),
136+ )
142 }137 }
M internal/ui/systemviews/theme.go +3 -2
@@ -19,6 +19,7 @@ const (
1919 )
2020
2121 var cssNamedColors = func() map[string]struct{} {
22+ //nolint:lll // CSS named color list is clearer as a single literal.
2223 names := strings.Fields(`aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet brown burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk crimson cyan darkblue darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki darkmagenta darkolivegreen darkorange darkorchid darkred darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ivory khaki lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan lightgoldenrodyellow lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen linen magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen mediumslateblue mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream mistyrose moccasin navajowhite navy oldlace olive olivedrab orange orangered orchid palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru pink plum powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen steelblue tan teal thistle tomato turquoise violet wheat white whitesmoke yellow yellowgreen`)
2324 colors := make(map[string]struct{}, len(names))
2425 for _, name := range names {
@@ -143,7 +144,7 @@ func isHexColor(value string) bool {
143144 return false
144145 }
145146 for _, r := range value[1:] {
146- if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
147+ if (r < '0' || r > '9') && (r < 'a' || r > 'f') && (r < 'A' || r > 'F') {
147148 return false
148149 }
149150 }
@@ -163,7 +164,7 @@ func isFunctionalColor(value string) bool {
163164 return false
164165 }
165166 for _, r := range strings.TrimSuffix(value[strings.IndexByte(value, '(')+1:], ")") {
166- if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || strings.ContainsRune(" .,%/-", r)) {
167+ if (r < '0' || r > '9') && (r < 'a' || r > 'z') && !strings.ContainsRune(" .,%/-", r) {
167168 return false
168169 }
169170 }
@@ -19,6 +19,7 @@ const (
19 )19 )
20 20
21 var cssNamedColors = func() map[string]struct{} {21 var cssNamedColors = func() map[string]struct{} {
22+ //nolint:lll // CSS named color list is clearer as a single literal.
22 names := strings.Fields(`aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet brown burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk crimson cyan darkblue darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki darkmagenta darkolivegreen darkorange darkorchid darkred darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ivory khaki lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan lightgoldenrodyellow lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen linen magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen mediumslateblue mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream mistyrose moccasin navajowhite navy oldlace olive olivedrab orange orangered orchid palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru pink plum powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen steelblue tan teal thistle tomato turquoise violet wheat white whitesmoke yellow yellowgreen`)23 names := strings.Fields(`aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet brown burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk crimson cyan darkblue darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki darkmagenta darkolivegreen darkorange darkorchid darkred darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ivory khaki lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan lightgoldenrodyellow lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen linen magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen mediumslateblue mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream mistyrose moccasin navajowhite navy oldlace olive olivedrab orange orangered orchid palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru pink plum powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen steelblue tan teal thistle tomato turquoise violet wheat white whitesmoke yellow yellowgreen`)
23 colors := make(map[string]struct{}, len(names))24 colors := make(map[string]struct{}, len(names))
24 for _, name := range names {25 for _, name := range names {
@@ -143,7 +144,7 @@ func isHexColor(value string) bool {
143 return false144 return false
144 }145 }
145 for _, r := range value[1:] {146 for _, r := range value[1:] {
146- if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {147+ if (r < '0' || r > '9') && (r < 'a' || r > 'f') && (r < 'A' || r > 'F') {
147 return false148 return false
148 }149 }
149 }150 }
@@ -163,7 +164,7 @@ func isFunctionalColor(value string) bool {
163 return false164 return false
164 }165 }
165 for _, r := range strings.TrimSuffix(value[strings.IndexByte(value, '(')+1:], ")") {166 for _, r := range strings.TrimSuffix(value[strings.IndexByte(value, '(')+1:], ")") {
166- if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || strings.ContainsRune(" .,%/-", r)) {167+ if (r < '0' || r > '9') && (r < 'a' || r > 'z') && !strings.ContainsRune(" .,%/-", r) {
167 return false168 return false
168 }169 }
169 }170 }

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