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
@@ -778,152 +778,162 @@ func PerformanceSettings(config dto.SystemviewConfigPayload) templ.Component {
778778 return templ_7745c5c3_Err
779779 }
780780 }
781- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, ">Default</option> <option value=\"lite\"")
781+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, ">Default</option> <option value=\"balanced\"")
782782 if templ_7745c5c3_Err != nil {
783783 return templ_7745c5c3_Err
784784 }
785- if performanceProfileSelected(config.Performance.Profile, "lite") {
785+ if performanceProfileSelected(config.Performance.Profile, "balanced") {
786786 templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " selected")
787787 if templ_7745c5c3_Err != nil {
788788 return templ_7745c5c3_Err
789789 }
790790 }
791- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, ">Lite</option> <option value=\"max\"")
791+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, ">Balanced</option> <option value=\"lite\"")
792792 if templ_7745c5c3_Err != nil {
793793 return templ_7745c5c3_Err
794794 }
795- if performanceProfileSelected(config.Performance.Profile, "max") {
795+ if performanceProfileSelected(config.Performance.Profile, "lite") {
796796 templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " selected")
797797 if templ_7745c5c3_Err != nil {
798798 return templ_7745c5c3_Err
799799 }
800800 }
801- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, ">Max</option> <option value=\"custom\"")
801+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, ">Lite</option> <option value=\"max\"")
802802 if templ_7745c5c3_Err != nil {
803803 return templ_7745c5c3_Err
804804 }
805- if performanceProfileSelected(config.Performance.Profile, "custom") {
805+ if performanceProfileSelected(config.Performance.Profile, "max") {
806806 templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " selected")
807807 if templ_7745c5c3_Err != nil {
808808 return templ_7745c5c3_Err
809809 }
810810 }
811- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, ">Custom</option></select></label><p class=\"sv-meta\">Choose Custom to edit individual performance values.</p><label><span>Skia CPU threads</span><input name=\"skia_cpu_threads\" type=\"number\" min=\"0\" max=\"8\" value=\"")
811+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, ">Max</option> <option value=\"custom\"")
812+ if templ_7745c5c3_Err != nil {
813+ return templ_7745c5c3_Err
814+ }
815+ if performanceProfileSelected(config.Performance.Profile, "custom") {
816+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, " selected")
817+ if templ_7745c5c3_Err != nil {
818+ return templ_7745c5c3_Err
819+ }
820+ }
821+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, ">Custom</option></select></label><p class=\"sv-meta\">Choose Custom to edit individual performance values.</p><label><span>Skia CPU threads</span><input name=\"skia_cpu_threads\" type=\"number\" min=\"0\" max=\"8\" value=\"")
812822 if templ_7745c5c3_Err != nil {
813823 return templ_7745c5c3_Err
814824 }
815825 var templ_7745c5c3_Var39 string
816826 templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Custom.SkiaCPUThreads))
817827 if templ_7745c5c3_Err != nil {
818- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 113, Col: 164}
828+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 114, Col: 164}
819829 }
820830 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
821831 if templ_7745c5c3_Err != nil {
822832 return templ_7745c5c3_Err
823833 }
824- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\"")
834+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\"")
825835 if templ_7745c5c3_Err != nil {
826836 return templ_7745c5c3_Err
827837 }
828838 if !customProfile {
829- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, " disabled")
839+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, " disabled")
830840 if templ_7745c5c3_Err != nil {
831841 return templ_7745c5c3_Err
832842 }
833843 }
834- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, " data-sv-performance-custom></label> <label><span>Skia GPU threads</span><input name=\"skia_gpu_threads\" type=\"number\" min=\"-1\" max=\"8\" value=\"")
844+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, " data-sv-performance-custom></label> <label><span>Skia GPU threads</span><input name=\"skia_gpu_threads\" type=\"number\" min=\"-1\" max=\"8\" value=\"")
835845 if templ_7745c5c3_Err != nil {
836846 return templ_7745c5c3_Err
837847 }
838848 var templ_7745c5c3_Var40 string
839849 templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Custom.SkiaGPUThreads))
840850 if templ_7745c5c3_Err != nil {
841- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 114, Col: 165}
851+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 115, Col: 165}
842852 }
843853 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
844854 if templ_7745c5c3_Err != nil {
845855 return templ_7745c5c3_Err
846856 }
847- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\"")
857+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\"")
848858 if templ_7745c5c3_Err != nil {
849859 return templ_7745c5c3_Err
850860 }
851861 if !customProfile {
852- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, " disabled")
862+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " disabled")
853863 if templ_7745c5c3_Err != nil {
854864 return templ_7745c5c3_Err
855865 }
856866 }
857- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, " data-sv-performance-custom></label> <label><span>Web process memory MB</span><input name=\"web_process_memory_mb\" type=\"number\" min=\"0\" max=\"16384\" value=\"")
867+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, " data-sv-performance-custom></label> <label><span>Web process memory MB</span><input name=\"web_process_memory_mb\" type=\"number\" min=\"0\" max=\"16384\" value=\"")
858868 if templ_7745c5c3_Err != nil {
859869 return templ_7745c5c3_Err
860870 }
861871 var templ_7745c5c3_Var41 string
862872 templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Custom.WebProcessMemoryMB))
863873 if templ_7745c5c3_Err != nil {
864- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 115, Col: 182}
874+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 116, Col: 182}
865875 }
866876 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
867877 if templ_7745c5c3_Err != nil {
868878 return templ_7745c5c3_Err
869879 }
870- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\"")
880+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\"")
871881 if templ_7745c5c3_Err != nil {
872882 return templ_7745c5c3_Err
873883 }
874884 if !customProfile {
875- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, " disabled")
885+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, " disabled")
876886 if templ_7745c5c3_Err != nil {
877887 return templ_7745c5c3_Err
878888 }
879889 }
880- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " data-sv-performance-custom></label> <label><span>Network process memory MB</span><input name=\"network_process_memory_mb\" type=\"number\" min=\"0\" max=\"4096\" value=\"")
890+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " data-sv-performance-custom></label> <label><span>Network process memory MB</span><input name=\"network_process_memory_mb\" type=\"number\" min=\"0\" max=\"4096\" value=\"")
881891 if templ_7745c5c3_Err != nil {
882892 return templ_7745c5c3_Err
883893 }
884894 var templ_7745c5c3_Var42 string
885895 templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Custom.NetworkProcessMemoryMB))
886896 if templ_7745c5c3_Err != nil {
887- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 116, Col: 193}
897+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 117, Col: 193}
888898 }
889899 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
890900 if templ_7745c5c3_Err != nil {
891901 return templ_7745c5c3_Err
892902 }
893- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\"")
903+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "\"")
894904 if templ_7745c5c3_Err != nil {
895905 return templ_7745c5c3_Err
896906 }
897907 if !customProfile {
898- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " disabled")
908+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, " disabled")
899909 if templ_7745c5c3_Err != nil {
900910 return templ_7745c5c3_Err
901911 }
902912 }
903- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, " data-sv-performance-custom></label> <label><span>WebView pool prewarm</span><input name=\"webview_pool_prewarm\" type=\"number\" min=\"0\" max=\"20\" value=\"")
913+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, " data-sv-performance-custom></label> <label><span>WebView pool prewarm</span><input name=\"webview_pool_prewarm\" type=\"number\" min=\"0\" max=\"20\" value=\"")
904914 if templ_7745c5c3_Err != nil {
905915 return templ_7745c5c3_Err
906916 }
907917 var templ_7745c5c3_Var43 string
908918 templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Custom.WebViewPoolPrewarm))
909919 if templ_7745c5c3_Err != nil {
910- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 117, Col: 177}
920+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 118, Col: 177}
911921 }
912922 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
913923 if templ_7745c5c3_Err != nil {
914924 return templ_7745c5c3_Err
915925 }
916- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\"")
926+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\"")
917927 if templ_7745c5c3_Err != nil {
918928 return templ_7745c5c3_Err
919929 }
920930 if !customProfile {
921- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, " disabled")
931+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, " disabled")
922932 if templ_7745c5c3_Err != nil {
923933 return templ_7745c5c3_Err
924934 }
925935 }
926- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, " data-sv-performance-custom></label> <button class=\"sv-button\" type=\"submit\">")
936+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, " data-sv-performance-custom></label> <button class=\"sv-button\" type=\"submit\">")
927937 if templ_7745c5c3_Err != nil {
928938 return templ_7745c5c3_Err
929939 }
@@ -931,69 +941,69 @@ func PerformanceSettings(config dto.SystemviewConfigPayload) templ.Component {
931941 if templ_7745c5c3_Err != nil {
932942 return templ_7745c5c3_Err
933943 }
934- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</button></form><div class=\"sv-stat-grid\"><div class=\"sv-stat-card\"><span class=\"sv-stat-value\">")
944+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</button></form><div class=\"sv-stat-grid\"><div class=\"sv-stat-card\"><span class=\"sv-stat-value\">")
935945 if templ_7745c5c3_Err != nil {
936946 return templ_7745c5c3_Err
937947 }
938948 var templ_7745c5c3_Var44 string
939949 templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Hardware.CPUThreads))
940950 if templ_7745c5c3_Err != nil {
941- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 121, Col: 113}
951+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 122, Col: 113}
942952 }
943953 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
944954 if templ_7745c5c3_Err != nil {
945955 return templ_7745c5c3_Err
946956 }
947- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</span><span class=\"sv-stat-label\">CPU threads</span></div><div class=\"sv-stat-card\"><span class=\"sv-stat-value\">")
957+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</span><span class=\"sv-stat-label\">CPU threads</span></div><div class=\"sv-stat-card\"><span class=\"sv-stat-value\">")
948958 if templ_7745c5c3_Err != nil {
949959 return templ_7745c5c3_Err
950960 }
951961 var templ_7745c5c3_Var45 string
952962 templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Hardware.TotalRAMMB))
953963 if templ_7745c5c3_Err != nil {
954- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 122, Col: 113}
964+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 123, Col: 113}
955965 }
956966 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
957967 if templ_7745c5c3_Err != nil {
958968 return templ_7745c5c3_Err
959969 }
960- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</span><span class=\"sv-stat-label\">RAM MB</span></div><div class=\"sv-stat-card\"><span class=\"sv-stat-value\">")
970+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</span><span class=\"sv-stat-label\">RAM MB</span></div><div class=\"sv-stat-card\"><span class=\"sv-stat-value\">")
961971 if templ_7745c5c3_Err != nil {
962972 return templ_7745c5c3_Err
963973 }
964974 var templ_7745c5c3_Var46 string
965975 templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(config.Performance.Hardware.GPUName)
966976 if templ_7745c5c3_Err != nil {
967- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 123, Col: 93}
977+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 124, Col: 93}
968978 }
969979 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
970980 if templ_7745c5c3_Err != nil {
971981 return templ_7745c5c3_Err
972982 }
973- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</span><span class=\"sv-stat-label\">GPU</span></div><div class=\"sv-stat-card\"><span class=\"sv-stat-value\">")
983+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</span><span class=\"sv-stat-label\">GPU</span></div><div class=\"sv-stat-card\"><span class=\"sv-stat-value\">")
974984 if templ_7745c5c3_Err != nil {
975985 return templ_7745c5c3_Err
976986 }
977987 var templ_7745c5c3_Var47 string
978988 templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(config.EngineType)
979989 if templ_7745c5c3_Err != nil {
980- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 124, Col: 75}
990+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 125, Col: 75}
981991 }
982992 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
983993 if templ_7745c5c3_Err != nil {
984994 return templ_7745c5c3_Err
985995 }
986- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</span><span class=\"sv-stat-label\">Engine</span></div></div>")
996+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</span><span class=\"sv-stat-label\">Engine</span></div></div>")
987997 if templ_7745c5c3_Err != nil {
988998 return templ_7745c5c3_Err
989999 }
9901000 if !engineIsWebKit(config.EngineType) {
991- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<p class=\"sv-meta\">Performance tuning applies only to the WebKit engine.</p>")
1001+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<p class=\"sv-meta\">Performance tuning applies only to the WebKit engine.</p>")
9921002 if templ_7745c5c3_Err != nil {
9931003 return templ_7745c5c3_Err
9941004 }
9951005 } else {
996- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<p class=\"sv-meta\">Performance profile changes may require restarting the browser to affect existing engine processes.</p>")
1006+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<p class=\"sv-meta\">Performance profile changes may require restarting the browser to affect existing engine processes.</p>")
9971007 if templ_7745c5c3_Err != nil {
9981008 return templ_7745c5c3_Err
9991009 }
@@ -1035,7 +1045,7 @@ func ConfigKeybindings(keybindings port.KeybindingsConfig) templ.Component {
10351045 return templ_7745c5c3_Err
10361046 }
10371047 } else {
1038- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<div class=\"sv-button-row\"><button class=\"sv-button sv-button-secondary sv-button-danger\" type=\"button\" data-sv-action=\"config.keybinding.resetAll\" data-sv-confirm=\"Reset all keybindings to defaults?\">")
1048+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "<div class=\"sv-button-row\"><button class=\"sv-button sv-button-secondary sv-button-danger\" type=\"button\" data-sv-action=\"config.keybinding.resetAll\" data-sv-confirm=\"Reset all keybindings to defaults?\">")
10391049 if templ_7745c5c3_Err != nil {
10401050 return templ_7745c5c3_Err
10411051 }
@@ -1043,7 +1053,7 @@ func ConfigKeybindings(keybindings port.KeybindingsConfig) templ.Component {
10431053 if templ_7745c5c3_Err != nil {
10441054 return templ_7745c5c3_Err
10451055 }
1046- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</button></div>")
1056+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "</button></div>")
10471057 if templ_7745c5c3_Err != nil {
10481058 return templ_7745c5c3_Err
10491059 }
@@ -1079,43 +1089,43 @@ func ConfigKeybindingGroup(group renderedKeybindingGroup) templ.Component {
10791089 templ_7745c5c3_Var49 = templ.NopComponent
10801090 }
10811091 ctx = templ.ClearChildren(ctx)
1082- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "<div class=\"sv-group\"><div class=\"sv-group-header\"><div><h4>")
1092+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "<div class=\"sv-group\"><div class=\"sv-group-header\"><div><h4>")
10831093 if templ_7745c5c3_Err != nil {
10841094 return templ_7745c5c3_Err
10851095 }
10861096 var templ_7745c5c3_Var50 string
10871097 templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingGroupTitle(group))
10881098 if templ_7745c5c3_Err != nil {
1089- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 153, Col: 37}
1099+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 154, Col: 37}
10901100 }
10911101 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
10921102 if templ_7745c5c3_Err != nil {
10931103 return templ_7745c5c3_Err
10941104 }
1095- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "</h4>")
1105+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</h4>")
10961106 if templ_7745c5c3_Err != nil {
10971107 return templ_7745c5c3_Err
10981108 }
10991109 if keybindingGroupMeta(group) != "" {
1100- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "<p class=\"sv-meta\">")
1110+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "<p class=\"sv-meta\">")
11011111 if templ_7745c5c3_Err != nil {
11021112 return templ_7745c5c3_Err
11031113 }
11041114 var templ_7745c5c3_Var51 string
11051115 templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingGroupMeta(group))
11061116 if templ_7745c5c3_Err != nil {
1107- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 155, Col: 52}
1117+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 156, Col: 52}
11081118 }
11091119 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
11101120 if templ_7745c5c3_Err != nil {
11111121 return templ_7745c5c3_Err
11121122 }
1113- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</p>")
1123+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</p>")
11141124 if templ_7745c5c3_Err != nil {
11151125 return templ_7745c5c3_Err
11161126 }
11171127 }
1118- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</div></div><div class=\"sv-group-body\">")
1128+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</div></div><div class=\"sv-group-body\">")
11191129 if templ_7745c5c3_Err != nil {
11201130 return templ_7745c5c3_Err
11211131 }
@@ -1125,7 +1135,7 @@ func ConfigKeybindingGroup(group renderedKeybindingGroup) templ.Component {
11251135 return templ_7745c5c3_Err
11261136 }
11271137 } else {
1128- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "<ul class=\"sv-list\">")
1138+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "<ul class=\"sv-list\">")
11291139 if templ_7745c5c3_Err != nil {
11301140 return templ_7745c5c3_Err
11311141 }
@@ -1135,12 +1145,12 @@ func ConfigKeybindingGroup(group renderedKeybindingGroup) templ.Component {
11351145 return templ_7745c5c3_Err
11361146 }
11371147 }
1138- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</ul>")
1148+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "</ul>")
11391149 if templ_7745c5c3_Err != nil {
11401150 return templ_7745c5c3_Err
11411151 }
11421152 }
1143- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</div></div>")
1153+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "</div></div>")
11441154 if templ_7745c5c3_Err != nil {
11451155 return templ_7745c5c3_Err
11461156 }
@@ -1169,33 +1179,33 @@ func ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding)
11691179 templ_7745c5c3_Var52 = templ.NopComponent
11701180 }
11711181 ctx = templ.ClearChildren(ctx)
1172- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<li class=\"sv-list-row\"><div class=\"sv-keybinding-row\"><div><strong>")
1182+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "<li class=\"sv-list-row\"><div class=\"sv-keybinding-row\"><div><strong>")
11731183 if templ_7745c5c3_Err != nil {
11741184 return templ_7745c5c3_Err
11751185 }
11761186 var templ_7745c5c3_Var53 string
11771187 templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingDescription(binding))
11781188 if templ_7745c5c3_Err != nil {
1179- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 177, Col: 44}
1189+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 178, Col: 44}
11801190 }
11811191 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
11821192 if templ_7745c5c3_Err != nil {
11831193 return templ_7745c5c3_Err
11841194 }
1185- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "</strong><p class=\"sv-meta\">")
1195+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "</strong><p class=\"sv-meta\">")
11861196 if templ_7745c5c3_Err != nil {
11871197 return templ_7745c5c3_Err
11881198 }
11891199 var templ_7745c5c3_Var54 string
11901200 templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingAction(binding))
11911201 if templ_7745c5c3_Err != nil {
1192- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 178, Col: 50}
1202+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 179, Col: 50}
11931203 }
11941204 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
11951205 if templ_7745c5c3_Err != nil {
11961206 return templ_7745c5c3_Err
11971207 }
1198- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "</p><div class=\"sv-binding-keys\">")
1208+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "</p><div class=\"sv-binding-keys\">")
11991209 if templ_7745c5c3_Err != nil {
12001210 return templ_7745c5c3_Err
12011211 }
@@ -1203,25 +1213,25 @@ func ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding)
12031213 if templ_7745c5c3_Err != nil {
12041214 return templ_7745c5c3_Err
12051215 }
1206- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "<span class=\"sv-badge\">")
1216+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "<span class=\"sv-badge\">")
12071217 if templ_7745c5c3_Err != nil {
12081218 return templ_7745c5c3_Err
12091219 }
12101220 var templ_7745c5c3_Var55 string
12111221 templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingStatus(binding))
12121222 if templ_7745c5c3_Err != nil {
1213- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 181, Col: 55}
1223+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 182, Col: 55}
12141224 }
12151225 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
12161226 if templ_7745c5c3_Err != nil {
12171227 return templ_7745c5c3_Err
12181228 }
1219- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "</span> ")
1229+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "</span> ")
12201230 if templ_7745c5c3_Err != nil {
12211231 return templ_7745c5c3_Err
12221232 }
12231233 if keybindingIsCustom(binding) && len(binding.DefaultKeys) > 0 {
1224- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "<span class=\"sv-meta\">default:</span>")
1234+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "<span class=\"sv-meta\">default:</span>")
12251235 if templ_7745c5c3_Err != nil {
12261236 return templ_7745c5c3_Err
12271237 }
@@ -1230,46 +1240,46 @@ func ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding)
12301240 return templ_7745c5c3_Err
12311241 }
12321242 }
1233- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "</div></div><form class=\"sv-inline-form\" data-sv-action=\"config.keybinding.set\"><input type=\"hidden\" name=\"mode\" value=\"")
1243+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "</div></div><form class=\"sv-inline-form\" data-sv-action=\"config.keybinding.set\"><input type=\"hidden\" name=\"mode\" value=\"")
12341244 if templ_7745c5c3_Err != nil {
12351245 return templ_7745c5c3_Err
12361246 }
12371247 var templ_7745c5c3_Var56 string
12381248 templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(group.Mode)
12391249 if templ_7745c5c3_Err != nil {
1240- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 189, Col: 55}
1250+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 190, Col: 55}
12411251 }
12421252 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
12431253 if templ_7745c5c3_Err != nil {
12441254 return templ_7745c5c3_Err
12451255 }
1246- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "\"> <input type=\"hidden\" name=\"action\" value=\"")
1256+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "\"> <input type=\"hidden\" name=\"action\" value=\"")
12471257 if templ_7745c5c3_Err != nil {
12481258 return templ_7745c5c3_Err
12491259 }
12501260 var templ_7745c5c3_Var57 string
12511261 templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(binding.Action)
12521262 if templ_7745c5c3_Err != nil {
1253- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 190, Col: 61}
1263+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 191, Col: 61}
12541264 }
12551265 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))
12561266 if templ_7745c5c3_Err != nil {
12571267 return templ_7745c5c3_Err
12581268 }
1259- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "\"> <label><span>Keys</span><input name=\"keys\" type=\"text\" value=\"")
1269+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "\"> <label><span>Keys</span><input name=\"keys\" type=\"text\" value=\"")
12601270 if templ_7745c5c3_Err != nil {
12611271 return templ_7745c5c3_Err
12621272 }
12631273 var templ_7745c5c3_Var58 string
12641274 templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingKeysValue(binding.Keys))
12651275 if templ_7745c5c3_Err != nil {
1266- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 191, Col: 100}
1276+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 192, Col: 100}
12671277 }
12681278 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58))
12691279 if templ_7745c5c3_Err != nil {
12701280 return templ_7745c5c3_Err
12711281 }
1272- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "\" placeholder=\"ctrl+h, ctrl+shift+h\" required></label> <button class=\"sv-button sv-button-secondary\" type=\"submit\">")
1282+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "\" placeholder=\"ctrl+h, ctrl+shift+h\" required></label> <button class=\"sv-button sv-button-secondary\" type=\"submit\">")
12731283 if templ_7745c5c3_Err != nil {
12741284 return templ_7745c5c3_Err
12751285 }
@@ -1277,33 +1287,33 @@ func ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding)
12771287 if templ_7745c5c3_Err != nil {
12781288 return templ_7745c5c3_Err
12791289 }
1280- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "</button> <button class=\"sv-button sv-button-secondary\" type=\"button\" data-sv-action=\"config.keybinding.reset\" data-mode=\"")
1290+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "</button> <button class=\"sv-button sv-button-secondary\" type=\"button\" data-sv-action=\"config.keybinding.reset\" data-mode=\"")
12811291 if templ_7745c5c3_Err != nil {
12821292 return templ_7745c5c3_Err
12831293 }
12841294 var templ_7745c5c3_Var59 string
12851295 templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(group.Mode)
12861296 if templ_7745c5c3_Err != nil {
1287- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 193, Col: 127}
1297+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 194, Col: 127}
12881298 }
12891299 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59))
12901300 if templ_7745c5c3_Err != nil {
12911301 return templ_7745c5c3_Err
12921302 }
1293- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "\" data-action=\"")
1303+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "\" data-action=\"")
12941304 if templ_7745c5c3_Err != nil {
12951305 return templ_7745c5c3_Err
12961306 }
12971307 var templ_7745c5c3_Var60 string
12981308 templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(binding.Action)
12991309 if templ_7745c5c3_Err != nil {
1300- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 193, Col: 158}
1310+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 194, Col: 158}
13011311 }
13021312 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60))
13031313 if templ_7745c5c3_Err != nil {
13041314 return templ_7745c5c3_Err
13051315 }
1306- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "\">")
1316+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "\">")
13071317 if templ_7745c5c3_Err != nil {
13081318 return templ_7745c5c3_Err
13091319 }
@@ -1311,7 +1321,7 @@ func ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding)
13111321 if templ_7745c5c3_Err != nil {
13121322 return templ_7745c5c3_Err
13131323 }
1314- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "</button></form></div></li>")
1324+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "</button></form></div></li>")
13151325 if templ_7745c5c3_Err != nil {
13161326 return templ_7745c5c3_Err
13171327 }
@@ -778,152 +778,162 @@ func PerformanceSettings(config dto.SystemviewConfigPayload) templ.Component {
778 return templ_7745c5c3_Err778 return templ_7745c5c3_Err
779 }779 }
780 }780 }
781- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, ">Default</option> <option value=\"lite\"")781+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, ">Default</option> <option value=\"balanced\"")
782 if templ_7745c5c3_Err != nil {782 if templ_7745c5c3_Err != nil {
783 return templ_7745c5c3_Err783 return templ_7745c5c3_Err
784 }784 }
785- if performanceProfileSelected(config.Performance.Profile, "lite") {785+ if performanceProfileSelected(config.Performance.Profile, "balanced") {
786 templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " selected")786 templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " selected")
787 if templ_7745c5c3_Err != nil {787 if templ_7745c5c3_Err != nil {
788 return templ_7745c5c3_Err788 return templ_7745c5c3_Err
789 }789 }
790 }790 }
791- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, ">Lite</option> <option value=\"max\"")791+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, ">Balanced</option> <option value=\"lite\"")
792 if templ_7745c5c3_Err != nil {792 if templ_7745c5c3_Err != nil {
793 return templ_7745c5c3_Err793 return templ_7745c5c3_Err
794 }794 }
795- if performanceProfileSelected(config.Performance.Profile, "max") {795+ if performanceProfileSelected(config.Performance.Profile, "lite") {
796 templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " selected")796 templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " selected")
797 if templ_7745c5c3_Err != nil {797 if templ_7745c5c3_Err != nil {
798 return templ_7745c5c3_Err798 return templ_7745c5c3_Err
799 }799 }
800 }800 }
801- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, ">Max</option> <option value=\"custom\"")801+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, ">Lite</option> <option value=\"max\"")
802 if templ_7745c5c3_Err != nil {802 if templ_7745c5c3_Err != nil {
803 return templ_7745c5c3_Err803 return templ_7745c5c3_Err
804 }804 }
805- if performanceProfileSelected(config.Performance.Profile, "custom") {805+ if performanceProfileSelected(config.Performance.Profile, "max") {
806 templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " selected")806 templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " selected")
807 if templ_7745c5c3_Err != nil {807 if templ_7745c5c3_Err != nil {
808 return templ_7745c5c3_Err808 return templ_7745c5c3_Err
809 }809 }
810 }810 }
811- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, ">Custom</option></select></label><p class=\"sv-meta\">Choose Custom to edit individual performance values.</p><label><span>Skia CPU threads</span><input name=\"skia_cpu_threads\" type=\"number\" min=\"0\" max=\"8\" value=\"")811+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, ">Max</option> <option value=\"custom\"")
812+ if templ_7745c5c3_Err != nil {
813+ return templ_7745c5c3_Err
814+ }
815+ if performanceProfileSelected(config.Performance.Profile, "custom") {
816+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, " selected")
817+ if templ_7745c5c3_Err != nil {
818+ return templ_7745c5c3_Err
819+ }
820+ }
821+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, ">Custom</option></select></label><p class=\"sv-meta\">Choose Custom to edit individual performance values.</p><label><span>Skia CPU threads</span><input name=\"skia_cpu_threads\" type=\"number\" min=\"0\" max=\"8\" value=\"")
812 if templ_7745c5c3_Err != nil {822 if templ_7745c5c3_Err != nil {
813 return templ_7745c5c3_Err823 return templ_7745c5c3_Err
814 }824 }
815 var templ_7745c5c3_Var39 string825 var templ_7745c5c3_Var39 string
816 templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Custom.SkiaCPUThreads))826 templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Custom.SkiaCPUThreads))
817 if templ_7745c5c3_Err != nil {827 if templ_7745c5c3_Err != nil {
818- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 113, Col: 164}828+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 114, Col: 164}
819 }829 }
820 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))830 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
821 if templ_7745c5c3_Err != nil {831 if templ_7745c5c3_Err != nil {
822 return templ_7745c5c3_Err832 return templ_7745c5c3_Err
823 }833 }
824- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\"")834+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\"")
825 if templ_7745c5c3_Err != nil {835 if templ_7745c5c3_Err != nil {
826 return templ_7745c5c3_Err836 return templ_7745c5c3_Err
827 }837 }
828 if !customProfile {838 if !customProfile {
829- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, " disabled")839+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, " disabled")
830 if templ_7745c5c3_Err != nil {840 if templ_7745c5c3_Err != nil {
831 return templ_7745c5c3_Err841 return templ_7745c5c3_Err
832 }842 }
833 }843 }
834- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, " data-sv-performance-custom></label> <label><span>Skia GPU threads</span><input name=\"skia_gpu_threads\" type=\"number\" min=\"-1\" max=\"8\" value=\"")844+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, " data-sv-performance-custom></label> <label><span>Skia GPU threads</span><input name=\"skia_gpu_threads\" type=\"number\" min=\"-1\" max=\"8\" value=\"")
835 if templ_7745c5c3_Err != nil {845 if templ_7745c5c3_Err != nil {
836 return templ_7745c5c3_Err846 return templ_7745c5c3_Err
837 }847 }
838 var templ_7745c5c3_Var40 string848 var templ_7745c5c3_Var40 string
839 templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Custom.SkiaGPUThreads))849 templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Custom.SkiaGPUThreads))
840 if templ_7745c5c3_Err != nil {850 if templ_7745c5c3_Err != nil {
841- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 114, Col: 165}851+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 115, Col: 165}
842 }852 }
843 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))853 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
844 if templ_7745c5c3_Err != nil {854 if templ_7745c5c3_Err != nil {
845 return templ_7745c5c3_Err855 return templ_7745c5c3_Err
846 }856 }
847- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\"")857+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\"")
848 if templ_7745c5c3_Err != nil {858 if templ_7745c5c3_Err != nil {
849 return templ_7745c5c3_Err859 return templ_7745c5c3_Err
850 }860 }
851 if !customProfile {861 if !customProfile {
852- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, " disabled")862+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " disabled")
853 if templ_7745c5c3_Err != nil {863 if templ_7745c5c3_Err != nil {
854 return templ_7745c5c3_Err864 return templ_7745c5c3_Err
855 }865 }
856 }866 }
857- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, " data-sv-performance-custom></label> <label><span>Web process memory MB</span><input name=\"web_process_memory_mb\" type=\"number\" min=\"0\" max=\"16384\" value=\"")867+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, " data-sv-performance-custom></label> <label><span>Web process memory MB</span><input name=\"web_process_memory_mb\" type=\"number\" min=\"0\" max=\"16384\" value=\"")
858 if templ_7745c5c3_Err != nil {868 if templ_7745c5c3_Err != nil {
859 return templ_7745c5c3_Err869 return templ_7745c5c3_Err
860 }870 }
861 var templ_7745c5c3_Var41 string871 var templ_7745c5c3_Var41 string
862 templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Custom.WebProcessMemoryMB))872 templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Custom.WebProcessMemoryMB))
863 if templ_7745c5c3_Err != nil {873 if templ_7745c5c3_Err != nil {
864- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 115, Col: 182}874+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 116, Col: 182}
865 }875 }
866 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))876 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
867 if templ_7745c5c3_Err != nil {877 if templ_7745c5c3_Err != nil {
868 return templ_7745c5c3_Err878 return templ_7745c5c3_Err
869 }879 }
870- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\"")880+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\"")
871 if templ_7745c5c3_Err != nil {881 if templ_7745c5c3_Err != nil {
872 return templ_7745c5c3_Err882 return templ_7745c5c3_Err
873 }883 }
874 if !customProfile {884 if !customProfile {
875- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, " disabled")885+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, " disabled")
876 if templ_7745c5c3_Err != nil {886 if templ_7745c5c3_Err != nil {
877 return templ_7745c5c3_Err887 return templ_7745c5c3_Err
878 }888 }
879 }889 }
880- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " data-sv-performance-custom></label> <label><span>Network process memory MB</span><input name=\"network_process_memory_mb\" type=\"number\" min=\"0\" max=\"4096\" value=\"")890+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " data-sv-performance-custom></label> <label><span>Network process memory MB</span><input name=\"network_process_memory_mb\" type=\"number\" min=\"0\" max=\"4096\" value=\"")
881 if templ_7745c5c3_Err != nil {891 if templ_7745c5c3_Err != nil {
882 return templ_7745c5c3_Err892 return templ_7745c5c3_Err
883 }893 }
884 var templ_7745c5c3_Var42 string894 var templ_7745c5c3_Var42 string
885 templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Custom.NetworkProcessMemoryMB))895 templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Custom.NetworkProcessMemoryMB))
886 if templ_7745c5c3_Err != nil {896 if templ_7745c5c3_Err != nil {
887- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 116, Col: 193}897+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 117, Col: 193}
888 }898 }
889 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))899 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
890 if templ_7745c5c3_Err != nil {900 if templ_7745c5c3_Err != nil {
891 return templ_7745c5c3_Err901 return templ_7745c5c3_Err
892 }902 }
893- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\"")903+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "\"")
894 if templ_7745c5c3_Err != nil {904 if templ_7745c5c3_Err != nil {
895 return templ_7745c5c3_Err905 return templ_7745c5c3_Err
896 }906 }
897 if !customProfile {907 if !customProfile {
898- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " disabled")908+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, " disabled")
899 if templ_7745c5c3_Err != nil {909 if templ_7745c5c3_Err != nil {
900 return templ_7745c5c3_Err910 return templ_7745c5c3_Err
901 }911 }
902 }912 }
903- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, " data-sv-performance-custom></label> <label><span>WebView pool prewarm</span><input name=\"webview_pool_prewarm\" type=\"number\" min=\"0\" max=\"20\" value=\"")913+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, " data-sv-performance-custom></label> <label><span>WebView pool prewarm</span><input name=\"webview_pool_prewarm\" type=\"number\" min=\"0\" max=\"20\" value=\"")
904 if templ_7745c5c3_Err != nil {914 if templ_7745c5c3_Err != nil {
905 return templ_7745c5c3_Err915 return templ_7745c5c3_Err
906 }916 }
907 var templ_7745c5c3_Var43 string917 var templ_7745c5c3_Var43 string
908 templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Custom.WebViewPoolPrewarm))918 templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Custom.WebViewPoolPrewarm))
909 if templ_7745c5c3_Err != nil {919 if templ_7745c5c3_Err != nil {
910- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 117, Col: 177}920+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 118, Col: 177}
911 }921 }
912 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))922 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
913 if templ_7745c5c3_Err != nil {923 if templ_7745c5c3_Err != nil {
914 return templ_7745c5c3_Err924 return templ_7745c5c3_Err
915 }925 }
916- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\"")926+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\"")
917 if templ_7745c5c3_Err != nil {927 if templ_7745c5c3_Err != nil {
918 return templ_7745c5c3_Err928 return templ_7745c5c3_Err
919 }929 }
920 if !customProfile {930 if !customProfile {
921- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, " disabled")931+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, " disabled")
922 if templ_7745c5c3_Err != nil {932 if templ_7745c5c3_Err != nil {
923 return templ_7745c5c3_Err933 return templ_7745c5c3_Err
924 }934 }
925 }935 }
926- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, " data-sv-performance-custom></label> <button class=\"sv-button\" type=\"submit\">")936+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, " data-sv-performance-custom></label> <button class=\"sv-button\" type=\"submit\">")
927 if templ_7745c5c3_Err != nil {937 if templ_7745c5c3_Err != nil {
928 return templ_7745c5c3_Err938 return templ_7745c5c3_Err
929 }939 }
@@ -931,69 +941,69 @@ func PerformanceSettings(config dto.SystemviewConfigPayload) templ.Component {
931 if templ_7745c5c3_Err != nil {941 if templ_7745c5c3_Err != nil {
932 return templ_7745c5c3_Err942 return templ_7745c5c3_Err
933 }943 }
934- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</button></form><div class=\"sv-stat-grid\"><div class=\"sv-stat-card\"><span class=\"sv-stat-value\">")944+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</button></form><div class=\"sv-stat-grid\"><div class=\"sv-stat-card\"><span class=\"sv-stat-value\">")
935 if templ_7745c5c3_Err != nil {945 if templ_7745c5c3_Err != nil {
936 return templ_7745c5c3_Err946 return templ_7745c5c3_Err
937 }947 }
938 var templ_7745c5c3_Var44 string948 var templ_7745c5c3_Var44 string
939 templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Hardware.CPUThreads))949 templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Hardware.CPUThreads))
940 if templ_7745c5c3_Err != nil {950 if templ_7745c5c3_Err != nil {
941- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 121, Col: 113}951+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 122, Col: 113}
942 }952 }
943 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))953 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
944 if templ_7745c5c3_Err != nil {954 if templ_7745c5c3_Err != nil {
945 return templ_7745c5c3_Err955 return templ_7745c5c3_Err
946 }956 }
947- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</span><span class=\"sv-stat-label\">CPU threads</span></div><div class=\"sv-stat-card\"><span class=\"sv-stat-value\">")957+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</span><span class=\"sv-stat-label\">CPU threads</span></div><div class=\"sv-stat-card\"><span class=\"sv-stat-value\">")
948 if templ_7745c5c3_Err != nil {958 if templ_7745c5c3_Err != nil {
949 return templ_7745c5c3_Err959 return templ_7745c5c3_Err
950 }960 }
951 var templ_7745c5c3_Var45 string961 var templ_7745c5c3_Var45 string
952 templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Hardware.TotalRAMMB))962 templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Hardware.TotalRAMMB))
953 if templ_7745c5c3_Err != nil {963 if templ_7745c5c3_Err != nil {
954- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 122, Col: 113}964+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 123, Col: 113}
955 }965 }
956 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))966 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
957 if templ_7745c5c3_Err != nil {967 if templ_7745c5c3_Err != nil {
958 return templ_7745c5c3_Err968 return templ_7745c5c3_Err
959 }969 }
960- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</span><span class=\"sv-stat-label\">RAM MB</span></div><div class=\"sv-stat-card\"><span class=\"sv-stat-value\">")970+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</span><span class=\"sv-stat-label\">RAM MB</span></div><div class=\"sv-stat-card\"><span class=\"sv-stat-value\">")
961 if templ_7745c5c3_Err != nil {971 if templ_7745c5c3_Err != nil {
962 return templ_7745c5c3_Err972 return templ_7745c5c3_Err
963 }973 }
964 var templ_7745c5c3_Var46 string974 var templ_7745c5c3_Var46 string
965 templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(config.Performance.Hardware.GPUName)975 templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(config.Performance.Hardware.GPUName)
966 if templ_7745c5c3_Err != nil {976 if templ_7745c5c3_Err != nil {
967- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 123, Col: 93}977+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 124, Col: 93}
968 }978 }
969 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))979 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
970 if templ_7745c5c3_Err != nil {980 if templ_7745c5c3_Err != nil {
971 return templ_7745c5c3_Err981 return templ_7745c5c3_Err
972 }982 }
973- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</span><span class=\"sv-stat-label\">GPU</span></div><div class=\"sv-stat-card\"><span class=\"sv-stat-value\">")983+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</span><span class=\"sv-stat-label\">GPU</span></div><div class=\"sv-stat-card\"><span class=\"sv-stat-value\">")
974 if templ_7745c5c3_Err != nil {984 if templ_7745c5c3_Err != nil {
975 return templ_7745c5c3_Err985 return templ_7745c5c3_Err
976 }986 }
977 var templ_7745c5c3_Var47 string987 var templ_7745c5c3_Var47 string
978 templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(config.EngineType)988 templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(config.EngineType)
979 if templ_7745c5c3_Err != nil {989 if templ_7745c5c3_Err != nil {
980- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 124, Col: 75}990+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 125, Col: 75}
981 }991 }
982 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))992 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
983 if templ_7745c5c3_Err != nil {993 if templ_7745c5c3_Err != nil {
984 return templ_7745c5c3_Err994 return templ_7745c5c3_Err
985 }995 }
986- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</span><span class=\"sv-stat-label\">Engine</span></div></div>")996+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</span><span class=\"sv-stat-label\">Engine</span></div></div>")
987 if templ_7745c5c3_Err != nil {997 if templ_7745c5c3_Err != nil {
988 return templ_7745c5c3_Err998 return templ_7745c5c3_Err
989 }999 }
990 if !engineIsWebKit(config.EngineType) {1000 if !engineIsWebKit(config.EngineType) {
991- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<p class=\"sv-meta\">Performance tuning applies only to the WebKit engine.</p>")1001+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<p class=\"sv-meta\">Performance tuning applies only to the WebKit engine.</p>")
992 if templ_7745c5c3_Err != nil {1002 if templ_7745c5c3_Err != nil {
993 return templ_7745c5c3_Err1003 return templ_7745c5c3_Err
994 }1004 }
995 } else {1005 } else {
996- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<p class=\"sv-meta\">Performance profile changes may require restarting the browser to affect existing engine processes.</p>")1006+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<p class=\"sv-meta\">Performance profile changes may require restarting the browser to affect existing engine processes.</p>")
997 if templ_7745c5c3_Err != nil {1007 if templ_7745c5c3_Err != nil {
998 return templ_7745c5c3_Err1008 return templ_7745c5c3_Err
999 }1009 }
@@ -1035,7 +1045,7 @@ func ConfigKeybindings(keybindings port.KeybindingsConfig) templ.Component {
1035 return templ_7745c5c3_Err1045 return templ_7745c5c3_Err
1036 }1046 }
1037 } else {1047 } else {
1038- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<div class=\"sv-button-row\"><button class=\"sv-button sv-button-secondary sv-button-danger\" type=\"button\" data-sv-action=\"config.keybinding.resetAll\" data-sv-confirm=\"Reset all keybindings to defaults?\">")1048+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "<div class=\"sv-button-row\"><button class=\"sv-button sv-button-secondary sv-button-danger\" type=\"button\" data-sv-action=\"config.keybinding.resetAll\" data-sv-confirm=\"Reset all keybindings to defaults?\">")
1039 if templ_7745c5c3_Err != nil {1049 if templ_7745c5c3_Err != nil {
1040 return templ_7745c5c3_Err1050 return templ_7745c5c3_Err
1041 }1051 }
@@ -1043,7 +1053,7 @@ func ConfigKeybindings(keybindings port.KeybindingsConfig) templ.Component {
1043 if templ_7745c5c3_Err != nil {1053 if templ_7745c5c3_Err != nil {
1044 return templ_7745c5c3_Err1054 return templ_7745c5c3_Err
1045 }1055 }
1046- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</button></div>")1056+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "</button></div>")
1047 if templ_7745c5c3_Err != nil {1057 if templ_7745c5c3_Err != nil {
1048 return templ_7745c5c3_Err1058 return templ_7745c5c3_Err
1049 }1059 }
@@ -1079,43 +1089,43 @@ func ConfigKeybindingGroup(group renderedKeybindingGroup) templ.Component {
1079 templ_7745c5c3_Var49 = templ.NopComponent1089 templ_7745c5c3_Var49 = templ.NopComponent
1080 }1090 }
1081 ctx = templ.ClearChildren(ctx)1091 ctx = templ.ClearChildren(ctx)
1082- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "<div class=\"sv-group\"><div class=\"sv-group-header\"><div><h4>")1092+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "<div class=\"sv-group\"><div class=\"sv-group-header\"><div><h4>")
1083 if templ_7745c5c3_Err != nil {1093 if templ_7745c5c3_Err != nil {
1084 return templ_7745c5c3_Err1094 return templ_7745c5c3_Err
1085 }1095 }
1086 var templ_7745c5c3_Var50 string1096 var templ_7745c5c3_Var50 string
1087 templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingGroupTitle(group))1097 templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingGroupTitle(group))
1088 if templ_7745c5c3_Err != nil {1098 if templ_7745c5c3_Err != nil {
1089- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 153, Col: 37}1099+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 154, Col: 37}
1090 }1100 }
1091 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))1101 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
1092 if templ_7745c5c3_Err != nil {1102 if templ_7745c5c3_Err != nil {
1093 return templ_7745c5c3_Err1103 return templ_7745c5c3_Err
1094 }1104 }
1095- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "</h4>")1105+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</h4>")
1096 if templ_7745c5c3_Err != nil {1106 if templ_7745c5c3_Err != nil {
1097 return templ_7745c5c3_Err1107 return templ_7745c5c3_Err
1098 }1108 }
1099 if keybindingGroupMeta(group) != "" {1109 if keybindingGroupMeta(group) != "" {
1100- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "<p class=\"sv-meta\">")1110+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "<p class=\"sv-meta\">")
1101 if templ_7745c5c3_Err != nil {1111 if templ_7745c5c3_Err != nil {
1102 return templ_7745c5c3_Err1112 return templ_7745c5c3_Err
1103 }1113 }
1104 var templ_7745c5c3_Var51 string1114 var templ_7745c5c3_Var51 string
1105 templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingGroupMeta(group))1115 templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingGroupMeta(group))
1106 if templ_7745c5c3_Err != nil {1116 if templ_7745c5c3_Err != nil {
1107- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 155, Col: 52}1117+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 156, Col: 52}
1108 }1118 }
1109 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))1119 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
1110 if templ_7745c5c3_Err != nil {1120 if templ_7745c5c3_Err != nil {
1111 return templ_7745c5c3_Err1121 return templ_7745c5c3_Err
1112 }1122 }
1113- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</p>")1123+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</p>")
1114 if templ_7745c5c3_Err != nil {1124 if templ_7745c5c3_Err != nil {
1115 return templ_7745c5c3_Err1125 return templ_7745c5c3_Err
1116 }1126 }
1117 }1127 }
1118- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</div></div><div class=\"sv-group-body\">")1128+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</div></div><div class=\"sv-group-body\">")
1119 if templ_7745c5c3_Err != nil {1129 if templ_7745c5c3_Err != nil {
1120 return templ_7745c5c3_Err1130 return templ_7745c5c3_Err
1121 }1131 }
@@ -1125,7 +1135,7 @@ func ConfigKeybindingGroup(group renderedKeybindingGroup) templ.Component {
1125 return templ_7745c5c3_Err1135 return templ_7745c5c3_Err
1126 }1136 }
1127 } else {1137 } else {
1128- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "<ul class=\"sv-list\">")1138+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "<ul class=\"sv-list\">")
1129 if templ_7745c5c3_Err != nil {1139 if templ_7745c5c3_Err != nil {
1130 return templ_7745c5c3_Err1140 return templ_7745c5c3_Err
1131 }1141 }
@@ -1135,12 +1145,12 @@ func ConfigKeybindingGroup(group renderedKeybindingGroup) templ.Component {
1135 return templ_7745c5c3_Err1145 return templ_7745c5c3_Err
1136 }1146 }
1137 }1147 }
1138- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</ul>")1148+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "</ul>")
1139 if templ_7745c5c3_Err != nil {1149 if templ_7745c5c3_Err != nil {
1140 return templ_7745c5c3_Err1150 return templ_7745c5c3_Err
1141 }1151 }
1142 }1152 }
1143- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</div></div>")1153+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "</div></div>")
1144 if templ_7745c5c3_Err != nil {1154 if templ_7745c5c3_Err != nil {
1145 return templ_7745c5c3_Err1155 return templ_7745c5c3_Err
1146 }1156 }
@@ -1169,33 +1179,33 @@ func ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding)
1169 templ_7745c5c3_Var52 = templ.NopComponent1179 templ_7745c5c3_Var52 = templ.NopComponent
1170 }1180 }
1171 ctx = templ.ClearChildren(ctx)1181 ctx = templ.ClearChildren(ctx)
1172- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<li class=\"sv-list-row\"><div class=\"sv-keybinding-row\"><div><strong>")1182+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "<li class=\"sv-list-row\"><div class=\"sv-keybinding-row\"><div><strong>")
1173 if templ_7745c5c3_Err != nil {1183 if templ_7745c5c3_Err != nil {
1174 return templ_7745c5c3_Err1184 return templ_7745c5c3_Err
1175 }1185 }
1176 var templ_7745c5c3_Var53 string1186 var templ_7745c5c3_Var53 string
1177 templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingDescription(binding))1187 templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingDescription(binding))
1178 if templ_7745c5c3_Err != nil {1188 if templ_7745c5c3_Err != nil {
1179- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 177, Col: 44}1189+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 178, Col: 44}
1180 }1190 }
1181 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))1191 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
1182 if templ_7745c5c3_Err != nil {1192 if templ_7745c5c3_Err != nil {
1183 return templ_7745c5c3_Err1193 return templ_7745c5c3_Err
1184 }1194 }
1185- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "</strong><p class=\"sv-meta\">")1195+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "</strong><p class=\"sv-meta\">")
1186 if templ_7745c5c3_Err != nil {1196 if templ_7745c5c3_Err != nil {
1187 return templ_7745c5c3_Err1197 return templ_7745c5c3_Err
1188 }1198 }
1189 var templ_7745c5c3_Var54 string1199 var templ_7745c5c3_Var54 string
1190 templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingAction(binding))1200 templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingAction(binding))
1191 if templ_7745c5c3_Err != nil {1201 if templ_7745c5c3_Err != nil {
1192- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 178, Col: 50}1202+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 179, Col: 50}
1193 }1203 }
1194 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))1204 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
1195 if templ_7745c5c3_Err != nil {1205 if templ_7745c5c3_Err != nil {
1196 return templ_7745c5c3_Err1206 return templ_7745c5c3_Err
1197 }1207 }
1198- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "</p><div class=\"sv-binding-keys\">")1208+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "</p><div class=\"sv-binding-keys\">")
1199 if templ_7745c5c3_Err != nil {1209 if templ_7745c5c3_Err != nil {
1200 return templ_7745c5c3_Err1210 return templ_7745c5c3_Err
1201 }1211 }
@@ -1203,25 +1213,25 @@ func ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding)
1203 if templ_7745c5c3_Err != nil {1213 if templ_7745c5c3_Err != nil {
1204 return templ_7745c5c3_Err1214 return templ_7745c5c3_Err
1205 }1215 }
1206- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "<span class=\"sv-badge\">")1216+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "<span class=\"sv-badge\">")
1207 if templ_7745c5c3_Err != nil {1217 if templ_7745c5c3_Err != nil {
1208 return templ_7745c5c3_Err1218 return templ_7745c5c3_Err
1209 }1219 }
1210 var templ_7745c5c3_Var55 string1220 var templ_7745c5c3_Var55 string
1211 templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingStatus(binding))1221 templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingStatus(binding))
1212 if templ_7745c5c3_Err != nil {1222 if templ_7745c5c3_Err != nil {
1213- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 181, Col: 55}1223+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 182, Col: 55}
1214 }1224 }
1215 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))1225 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
1216 if templ_7745c5c3_Err != nil {1226 if templ_7745c5c3_Err != nil {
1217 return templ_7745c5c3_Err1227 return templ_7745c5c3_Err
1218 }1228 }
1219- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "</span> ")1229+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "</span> ")
1220 if templ_7745c5c3_Err != nil {1230 if templ_7745c5c3_Err != nil {
1221 return templ_7745c5c3_Err1231 return templ_7745c5c3_Err
1222 }1232 }
1223 if keybindingIsCustom(binding) && len(binding.DefaultKeys) > 0 {1233 if keybindingIsCustom(binding) && len(binding.DefaultKeys) > 0 {
1224- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "<span class=\"sv-meta\">default:</span>")1234+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "<span class=\"sv-meta\">default:</span>")
1225 if templ_7745c5c3_Err != nil {1235 if templ_7745c5c3_Err != nil {
1226 return templ_7745c5c3_Err1236 return templ_7745c5c3_Err
1227 }1237 }
@@ -1230,46 +1240,46 @@ func ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding)
1230 return templ_7745c5c3_Err1240 return templ_7745c5c3_Err
1231 }1241 }
1232 }1242 }
1233- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "</div></div><form class=\"sv-inline-form\" data-sv-action=\"config.keybinding.set\"><input type=\"hidden\" name=\"mode\" value=\"")1243+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "</div></div><form class=\"sv-inline-form\" data-sv-action=\"config.keybinding.set\"><input type=\"hidden\" name=\"mode\" value=\"")
1234 if templ_7745c5c3_Err != nil {1244 if templ_7745c5c3_Err != nil {
1235 return templ_7745c5c3_Err1245 return templ_7745c5c3_Err
1236 }1246 }
1237 var templ_7745c5c3_Var56 string1247 var templ_7745c5c3_Var56 string
1238 templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(group.Mode)1248 templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(group.Mode)
1239 if templ_7745c5c3_Err != nil {1249 if templ_7745c5c3_Err != nil {
1240- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 189, Col: 55}1250+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 190, Col: 55}
1241 }1251 }
1242 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))1252 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
1243 if templ_7745c5c3_Err != nil {1253 if templ_7745c5c3_Err != nil {
1244 return templ_7745c5c3_Err1254 return templ_7745c5c3_Err
1245 }1255 }
1246- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "\"> <input type=\"hidden\" name=\"action\" value=\"")1256+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "\"> <input type=\"hidden\" name=\"action\" value=\"")
1247 if templ_7745c5c3_Err != nil {1257 if templ_7745c5c3_Err != nil {
1248 return templ_7745c5c3_Err1258 return templ_7745c5c3_Err
1249 }1259 }
1250 var templ_7745c5c3_Var57 string1260 var templ_7745c5c3_Var57 string
1251 templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(binding.Action)1261 templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(binding.Action)
1252 if templ_7745c5c3_Err != nil {1262 if templ_7745c5c3_Err != nil {
1253- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 190, Col: 61}1263+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 191, Col: 61}
1254 }1264 }
1255 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))1265 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))
1256 if templ_7745c5c3_Err != nil {1266 if templ_7745c5c3_Err != nil {
1257 return templ_7745c5c3_Err1267 return templ_7745c5c3_Err
1258 }1268 }
1259- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "\"> <label><span>Keys</span><input name=\"keys\" type=\"text\" value=\"")1269+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "\"> <label><span>Keys</span><input name=\"keys\" type=\"text\" value=\"")
1260 if templ_7745c5c3_Err != nil {1270 if templ_7745c5c3_Err != nil {
1261 return templ_7745c5c3_Err1271 return templ_7745c5c3_Err
1262 }1272 }
1263 var templ_7745c5c3_Var58 string1273 var templ_7745c5c3_Var58 string
1264 templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingKeysValue(binding.Keys))1274 templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingKeysValue(binding.Keys))
1265 if templ_7745c5c3_Err != nil {1275 if templ_7745c5c3_Err != nil {
1266- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 191, Col: 100}1276+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 192, Col: 100}
1267 }1277 }
1268 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58))1278 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58))
1269 if templ_7745c5c3_Err != nil {1279 if templ_7745c5c3_Err != nil {
1270 return templ_7745c5c3_Err1280 return templ_7745c5c3_Err
1271 }1281 }
1272- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "\" placeholder=\"ctrl+h, ctrl+shift+h\" required></label> <button class=\"sv-button sv-button-secondary\" type=\"submit\">")1282+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "\" placeholder=\"ctrl+h, ctrl+shift+h\" required></label> <button class=\"sv-button sv-button-secondary\" type=\"submit\">")
1273 if templ_7745c5c3_Err != nil {1283 if templ_7745c5c3_Err != nil {
1274 return templ_7745c5c3_Err1284 return templ_7745c5c3_Err
1275 }1285 }
@@ -1277,33 +1287,33 @@ func ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding)
1277 if templ_7745c5c3_Err != nil {1287 if templ_7745c5c3_Err != nil {
1278 return templ_7745c5c3_Err1288 return templ_7745c5c3_Err
1279 }1289 }
1280- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "</button> <button class=\"sv-button sv-button-secondary\" type=\"button\" data-sv-action=\"config.keybinding.reset\" data-mode=\"")1290+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "</button> <button class=\"sv-button sv-button-secondary\" type=\"button\" data-sv-action=\"config.keybinding.reset\" data-mode=\"")
1281 if templ_7745c5c3_Err != nil {1291 if templ_7745c5c3_Err != nil {
1282 return templ_7745c5c3_Err1292 return templ_7745c5c3_Err
1283 }1293 }
1284 var templ_7745c5c3_Var59 string1294 var templ_7745c5c3_Var59 string
1285 templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(group.Mode)1295 templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(group.Mode)
1286 if templ_7745c5c3_Err != nil {1296 if templ_7745c5c3_Err != nil {
1287- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 193, Col: 127}1297+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 194, Col: 127}
1288 }1298 }
1289 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59))1299 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59))
1290 if templ_7745c5c3_Err != nil {1300 if templ_7745c5c3_Err != nil {
1291 return templ_7745c5c3_Err1301 return templ_7745c5c3_Err
1292 }1302 }
1293- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "\" data-action=\"")1303+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "\" data-action=\"")
1294 if templ_7745c5c3_Err != nil {1304 if templ_7745c5c3_Err != nil {
1295 return templ_7745c5c3_Err1305 return templ_7745c5c3_Err
1296 }1306 }
1297 var templ_7745c5c3_Var60 string1307 var templ_7745c5c3_Var60 string
1298 templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(binding.Action)1308 templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(binding.Action)
1299 if templ_7745c5c3_Err != nil {1309 if templ_7745c5c3_Err != nil {
1300- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 193, Col: 158}1310+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 194, Col: 158}
1301 }1311 }
1302 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60))1312 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60))
1303 if templ_7745c5c3_Err != nil {1313 if templ_7745c5c3_Err != nil {
1304 return templ_7745c5c3_Err1314 return templ_7745c5c3_Err
1305 }1315 }
1306- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "\">")1316+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "\">")
1307 if templ_7745c5c3_Err != nil {1317 if templ_7745c5c3_Err != nil {
1308 return templ_7745c5c3_Err1318 return templ_7745c5c3_Err
1309 }1319 }
@@ -1311,7 +1321,7 @@ func ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding)
1311 if templ_7745c5c3_Err != nil {1321 if templ_7745c5c3_Err != nil {
1312 return templ_7745c5c3_Err1322 return templ_7745c5c3_Err
1313 }1323 }
1314- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "</button></form></div></li>")1324+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "</button></form></div></li>")
1315 if templ_7745c5c3_Err != nil {1325 if templ_7745c5c3_Err != nil {
1316 return templ_7745c5c3_Err1326 return templ_7745c5c3_Err
1317 }1327 }
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