detached 279f5af

fix(systemviews): address review feedback

commit
279f5af49f1217c797bc49eb4eb87150a40966c6
author
brice · · 2026-04-24 08:18 UTC
parent
1ed380e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
42 files · +957 -231
M Makefile +1 -0
@@ -56,6 +56,7 @@ build-systemviews: generate-systemviews ## Build the WASM systemviews runtime
5656 @mkdir -p assets/systemviews
5757 @cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" assets/systemviews/wasm_exec.js
5858 GOOS=js GOARCH=wasm go build -o assets/systemviews/systemviews.wasm ./cmd/systemviews
59+ brotli -f assets/systemviews/systemviews.wasm -o assets/systemviews/systemviews.wasm.br
5960 @echo "Systemviews build complete"
6061
6162 build-quick: ## Build quickly for backend development
@@ -56,6 +56,7 @@ build-systemviews: generate-systemviews ## Build the WASM systemviews runtime
56 @mkdir -p assets/systemviews56 @mkdir -p assets/systemviews
57 @cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" assets/systemviews/wasm_exec.js57 @cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" assets/systemviews/wasm_exec.js
58 GOOS=js GOARCH=wasm go build -o assets/systemviews/systemviews.wasm ./cmd/systemviews58 GOOS=js GOARCH=wasm go build -o assets/systemviews/systemviews.wasm ./cmd/systemviews
59+ brotli -f assets/systemviews/systemviews.wasm -o assets/systemviews/systemviews.wasm.br
59 @echo "Systemviews build complete"60 @echo "Systemviews build complete"
60 61
61 build-quick: ## Build quickly for backend development62 build-quick: ## Build quickly for backend development
M assets/systemviews/index.html +16 -1
@@ -10,7 +10,22 @@
1010 <script>
1111 const go = new Go();
1212 WebAssembly.instantiateStreaming(fetch('./systemviews.wasm'), go.importObject)
13- .then((result) => go.run(result.instance));
13+ .then((result) => go.run(result.instance))
14+ .catch((err) => {
15+ const app = document.getElementById('app');
16+ if (app) {
17+ app.innerHTML = '';
18+ const message = document.createElement('div');
19+ message.className = 'sv-error';
20+ const prefix = document.createElement('span');
21+ prefix.textContent = 'Failed to load systemviews WebAssembly: ';
22+ const details = document.createElement('span');
23+ details.textContent = err && err.message ? err.message : String(err);
24+ message.append(prefix, details);
25+ app.append(message);
26+ }
27+ console.error('WASM load error:', err);
28+ });
1429 </script>
1530 </head>
1631 <body>
@@ -10,7 +10,22 @@
10 <script>10 <script>
11 const go = new Go();11 const go = new Go();
12 WebAssembly.instantiateStreaming(fetch('./systemviews.wasm'), go.importObject)12 WebAssembly.instantiateStreaming(fetch('./systemviews.wasm'), go.importObject)
13- .then((result) => go.run(result.instance));13+ .then((result) => go.run(result.instance))
14+ .catch((err) => {
15+ const app = document.getElementById('app');
16+ if (app) {
17+ app.innerHTML = '';
18+ const message = document.createElement('div');
19+ message.className = 'sv-error';
20+ const prefix = document.createElement('span');
21+ prefix.textContent = 'Failed to load systemviews WebAssembly: ';
22+ const details = document.createElement('span');
23+ details.textContent = err && err.message ? err.message : String(err);
24+ message.append(prefix, details);
25+ app.append(message);
26+ }
27+ console.error('WASM load error:', err);
28+ });
14 </script>29 </script>
15 </head>30 </head>
16 <body>31 <body>
A assets/systemviews/systemviews.wasm.br +0 -0 binary file: no text diff

binary file: no text diff

M aur/dumber-browser-git/PKGBUILD +1 -1
@@ -6,7 +6,7 @@ pkgdesc="A minimal keyboard-driven browser for tiling WMs (git version)"
66 arch=('x86_64')
77 url="https://github.com/bnema/dumber"
88 license=('MIT')
9-makedepends=('go>=1.25' 'git')
9+makedepends=('go>=1.26' 'git')
1010 depends=('gtk4' 'webkitgtk-6.0')
1111 optdepends=(
1212 'gst-plugins-base: Base media codecs'
@@ -6,7 +6,7 @@ pkgdesc="A minimal keyboard-driven browser for tiling WMs (git version)"
6 arch=('x86_64')6 arch=('x86_64')
7 url="https://github.com/bnema/dumber"7 url="https://github.com/bnema/dumber"
8 license=('MIT')8 license=('MIT')
9-makedepends=('go>=1.25' 'git')9+makedepends=('go>=1.26' 'git')
10 depends=('gtk4' 'webkitgtk-6.0')10 depends=('gtk4' 'webkitgtk-6.0')
11 optdepends=(11 optdepends=(
12 'gst-plugins-base: Base media codecs'12 'gst-plugins-base: Base media codecs'
M cmd/systemviews/bootstrap_test.go +6 -0
@@ -96,6 +96,7 @@ func (f *fakeDOM) Mount(html string) error {
9696 return nil
9797 }
9898
99+// Handwritten fake to capture bridge state for stateful render assertions.
99100 type fakeBridgeService struct {
100101 calledHistory bool
101102 calledFavorites bool
@@ -115,6 +116,11 @@ func (f *fakeBridgeService) Timeline(context.Context, int, int) ([]*entity.Histo
115116 return f.historyEntries, nil
116117 }
117118
119+func (f *fakeBridgeService) TimelineByDomain(context.Context, string, int, int) ([]*entity.HistoryEntry, error) {
120+ f.calledHistory = true
121+ return f.historyEntries, nil
122+}
123+
118124 func (*fakeBridgeService) Search(context.Context, string, int) ([]*entity.HistoryEntry, error) {
119125 return nil, nil
120126 }
@@ -96,6 +96,7 @@ func (f *fakeDOM) Mount(html string) error {
96 return nil96 return nil
97 }97 }
98 98
99+// Handwritten fake to capture bridge state for stateful render assertions.
99 type fakeBridgeService struct {100 type fakeBridgeService struct {
100 calledHistory bool101 calledHistory bool
101 calledFavorites bool102 calledFavorites bool
@@ -115,6 +116,11 @@ func (f *fakeBridgeService) Timeline(context.Context, int, int) ([]*entity.Histo
115 return f.historyEntries, nil116 return f.historyEntries, nil
116 }117 }
117 118
119+func (f *fakeBridgeService) TimelineByDomain(context.Context, string, int, int) ([]*entity.HistoryEntry, error) {
120+ f.calledHistory = true
121+ return f.historyEntries, nil
122+}
123+
118 func (*fakeBridgeService) Search(context.Context, string, int) ([]*entity.HistoryEntry, error) {124 func (*fakeBridgeService) Search(context.Context, string, int) ([]*entity.HistoryEntry, error) {
119 return nil, nil125 return nil, nil
120 }126 }
M cmd/systemviews/main.go +6 -1
@@ -11,7 +11,12 @@ import (
1111
1212 func main() {
1313 bridge := systemviewsbridge.NewBrowserClient()
14- app := newBridgeApp(systemviews.NewDOM(), js.Global().Get("location").Get("href").String(), bridge)
14+ href := ""
15+ location := js.Global().Get("location")
16+ if location.Type() != js.TypeUndefined && location.Type() != js.TypeNull {
17+ href = location.Get("href").String()
18+ }
19+ app := newBridgeApp(systemviews.NewDOM(), href, bridge)
1520
1621 if err := app.Run(); err != nil {
1722 js.Global().Get("console").Call("error", err.Error())
@@ -11,7 +11,12 @@ import (
11 11
12 func main() {12 func main() {
13 bridge := systemviewsbridge.NewBrowserClient()13 bridge := systemviewsbridge.NewBrowserClient()
14- app := newBridgeApp(systemviews.NewDOM(), js.Global().Get("location").Get("href").String(), bridge)14+ href := ""
15+ location := js.Global().Get("location")
16+ if location.Type() != js.TypeUndefined && location.Type() != js.TypeNull {
17+ href = location.Get("href").String()
18+ }
19+ app := newBridgeApp(systemviews.NewDOM(), href, bridge)
15 20
16 if err := app.Run(); err != nil {21 if err := app.Run(); err != nil {
17 js.Global().Get("console").Call("error", err.Error())22 js.Global().Get("console").Call("error", err.Error())
M docs/reference/keybindings.md +2 -2
@@ -80,7 +80,7 @@ These work outside modal modes:
8080 | Action | Keys |
8181 |--------|------|
8282 | Toggle floating pane | `Alt+F` |
83-| Toggle History system view in right split | `Ctrl+H` |
83+| Toggle History system view in right split (may conflict with the browser's default History shortcut; behavior can vary by browser) | `Ctrl+H` |
8484 | Toggle Favorites system view in right split | unbound by default |
8585 | Toggle Config system view in right split | unbound by default |
8686 | Close pane (or release floating pane) | `Ctrl+W` |
@@ -93,7 +93,7 @@ These work outside modal modes:
9393
9494 - `Alt+F` is the only floating-pane shortcut enabled by default.
9595 - `Alt+F` toggles floating visibility and keeps floating pane state intact.
96-- `Ctrl+H` toggles `dumb://history`: it focuses an existing History pane, opens it in a right split if missing, or closes it when already active.
96+- `Ctrl+H` toggles `dumb://history`: it focuses an existing History pane, opens it in a right split if missing, or closes it when already active. Depending on the browser, the default History shortcut may intercept `Ctrl+H`, so behavior can vary.
9797 - `Ctrl+W` closes the active pane; when the floating pane is active, it fully releases that floating session.
9898 - Any URL shortcut (for example `Alt+G`) must be defined explicitly in `workspace.floating_pane.profiles`.
9999 - Floating profile shortcuts support modifier combos with `ctrl`, `shift`, and `alt` (for example `ctrl+shift+y` or `ctrl+alt+m`).
@@ -80,7 +80,7 @@ These work outside modal modes:
80 | Action | Keys |80 | Action | Keys |
81 |--------|------|81 |--------|------|
82 | Toggle floating pane | `Alt+F` |82 | Toggle floating pane | `Alt+F` |
83-| Toggle History system view in right split | `Ctrl+H` |83+| Toggle History system view in right split (may conflict with the browser's default History shortcut; behavior can vary by browser) | `Ctrl+H` |
84 | Toggle Favorites system view in right split | unbound by default |84 | Toggle Favorites system view in right split | unbound by default |
85 | Toggle Config system view in right split | unbound by default |85 | Toggle Config system view in right split | unbound by default |
86 | Close pane (or release floating pane) | `Ctrl+W` |86 | Close pane (or release floating pane) | `Ctrl+W` |
@@ -93,7 +93,7 @@ These work outside modal modes:
93 93
94 - `Alt+F` is the only floating-pane shortcut enabled by default.94 - `Alt+F` is the only floating-pane shortcut enabled by default.
95 - `Alt+F` toggles floating visibility and keeps floating pane state intact.95 - `Alt+F` toggles floating visibility and keeps floating pane state intact.
96-- `Ctrl+H` toggles `dumb://history`: it focuses an existing History pane, opens it in a right split if missing, or closes it when already active.96+- `Ctrl+H` toggles `dumb://history`: it focuses an existing History pane, opens it in a right split if missing, or closes it when already active. Depending on the browser, the default History shortcut may intercept `Ctrl+H`, so behavior can vary.
97 - `Ctrl+W` closes the active pane; when the floating pane is active, it fully releases that floating session.97 - `Ctrl+W` closes the active pane; when the floating pane is active, it fully releases that floating session.
98 - Any URL shortcut (for example `Alt+G`) must be defined explicitly in `workspace.floating_pane.profiles`.98 - Any URL shortcut (for example `Alt+G`) must be defined explicitly in `workspace.floating_pane.profiles`.
99 - Floating profile shortcuts support modifier combos with `ctrl`, `shift`, and `alt` (for example `ctrl+shift+y` or `ctrl+alt+m`).99 - Floating profile shortcuts support modifier combos with `ctrl`, `shift`, and `alt` (for example `ctrl+shift+y` or `ctrl+alt+m`).
M internal/application/port/homepage.go +1 -0
@@ -21,6 +21,7 @@ type HistorySearchOutput struct {
2121 // HomepageHistory provides history operations needed by the WebUI homepage handlers.
2222 type HomepageHistory interface {
2323 GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
24+ GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error)
2425 Search(ctx context.Context, input HistorySearchInput) (*HistorySearchOutput, error)
2526 Delete(ctx context.Context, id int64) error
2627 ClearRange(ctx context.Context, rangeID string) error
@@ -21,6 +21,7 @@ type HistorySearchOutput struct {
21 // HomepageHistory provides history operations needed by the WebUI homepage handlers.21 // HomepageHistory provides history operations needed by the WebUI homepage handlers.
22 type HomepageHistory interface {22 type HomepageHistory interface {
23 GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)23 GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
24+ GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error)
24 Search(ctx context.Context, input HistorySearchInput) (*HistorySearchOutput, error)25 Search(ctx context.Context, input HistorySearchInput) (*HistorySearchOutput, error)
25 Delete(ctx context.Context, id int64) error26 Delete(ctx context.Context, id int64) error
26 ClearRange(ctx context.Context, rangeID string) error27 ClearRange(ctx context.Context, rangeID string) error
M internal/application/port/mocks/mocks.go +307 -34
@@ -6490,44 +6490,44 @@ func (_c *MockHomepageHistory_ClearAll_Call) RunAndReturn(run func(ctx context.C
64906490 return _c
64916491 }
64926492
6493-// ClearRange provides a mock function for the type MockHomepageHistory
6494-func (_mock *MockHomepageHistory) ClearRange(ctx context.Context, rangeID string) error {
6495- ret := _mock.Called(ctx, rangeID)
6493+// ClearOlderThan provides a mock function for the type MockHomepageHistory
6494+func (_mock *MockHomepageHistory) ClearOlderThan(ctx context.Context, before time.Time) error {
6495+ ret := _mock.Called(ctx, before)
64966496
64976497 if len(ret) == 0 {
6498- panic("no return value specified for ClearRange")
6498+ panic("no return value specified for ClearOlderThan")
64996499 }
65006500
65016501 var r0 error
6502- if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
6503- r0 = returnFunc(ctx, rangeID)
6502+ if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) error); ok {
6503+ r0 = returnFunc(ctx, before)
65046504 } else {
65056505 r0 = ret.Error(0)
65066506 }
65076507 return r0
65086508 }
65096509
6510-// MockHomepageHistory_ClearRange_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearRange'
6511-type MockHomepageHistory_ClearRange_Call struct {
6510+// MockHomepageHistory_ClearOlderThan_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearOlderThan'
6511+type MockHomepageHistory_ClearOlderThan_Call struct {
65126512 *mock.Call
65136513 }
65146514
6515-// ClearRange is a helper method to define mock.On call
6515+// ClearOlderThan is a helper method to define mock.On call
65166516 // - ctx context.Context
6517-// - rangeID string
6518-func (_e *MockHomepageHistory_Expecter) ClearRange(ctx interface{}, rangeID interface{}) *MockHomepageHistory_ClearRange_Call {
6519- return &MockHomepageHistory_ClearRange_Call{Call: _e.mock.On("ClearRange", ctx, rangeID)}
6517+// - before time.Time
6518+func (_e *MockHomepageHistory_Expecter) ClearOlderThan(ctx interface{}, before interface{}) *MockHomepageHistory_ClearOlderThan_Call {
6519+ return &MockHomepageHistory_ClearOlderThan_Call{Call: _e.mock.On("ClearOlderThan", ctx, before)}
65206520 }
65216521
6522-func (_c *MockHomepageHistory_ClearRange_Call) Run(run func(ctx context.Context, rangeID string)) *MockHomepageHistory_ClearRange_Call {
6522+func (_c *MockHomepageHistory_ClearOlderThan_Call) Run(run func(ctx context.Context, before time.Time)) *MockHomepageHistory_ClearOlderThan_Call {
65236523 _c.Call.Run(func(args mock.Arguments) {
65246524 var arg0 context.Context
65256525 if args[0] != nil {
65266526 arg0 = args[0].(context.Context)
65276527 }
6528- var arg1 string
6528+ var arg1 time.Time
65296529 if args[1] != nil {
6530- arg1 = args[1].(string)
6530+ arg1 = args[1].(time.Time)
65316531 }
65326532 run(
65336533 arg0,
@@ -6537,54 +6537,54 @@ func (_c *MockHomepageHistory_ClearRange_Call) Run(run func(ctx context.Context,
65376537 return _c
65386538 }
65396539
6540-func (_c *MockHomepageHistory_ClearRange_Call) Return(err error) *MockHomepageHistory_ClearRange_Call {
6540+func (_c *MockHomepageHistory_ClearOlderThan_Call) Return(err error) *MockHomepageHistory_ClearOlderThan_Call {
65416541 _c.Call.Return(err)
65426542 return _c
65436543 }
65446544
6545-func (_c *MockHomepageHistory_ClearRange_Call) RunAndReturn(run func(ctx context.Context, rangeID string) error) *MockHomepageHistory_ClearRange_Call {
6545+func (_c *MockHomepageHistory_ClearOlderThan_Call) RunAndReturn(run func(ctx context.Context, before time.Time) error) *MockHomepageHistory_ClearOlderThan_Call {
65466546 _c.Call.Return(run)
65476547 return _c
65486548 }
65496549
6550-// ClearOlderThan provides a mock function for the type MockHomepageHistory
6551-func (_mock *MockHomepageHistory) ClearOlderThan(ctx context.Context, before time.Time) error {
6552- ret := _mock.Called(ctx, before)
6550+// ClearRange provides a mock function for the type MockHomepageHistory
6551+func (_mock *MockHomepageHistory) ClearRange(ctx context.Context, rangeID string) error {
6552+ ret := _mock.Called(ctx, rangeID)
65536553
65546554 if len(ret) == 0 {
6555- panic("no return value specified for ClearOlderThan")
6555+ panic("no return value specified for ClearRange")
65566556 }
65576557
65586558 var r0 error
6559- if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) error); ok {
6560- r0 = returnFunc(ctx, before)
6559+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
6560+ r0 = returnFunc(ctx, rangeID)
65616561 } else {
65626562 r0 = ret.Error(0)
65636563 }
65646564 return r0
65656565 }
65666566
6567-// MockHomepageHistory_ClearOlderThan_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearOlderThan'
6568-type MockHomepageHistory_ClearOlderThan_Call struct {
6567+// MockHomepageHistory_ClearRange_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearRange'
6568+type MockHomepageHistory_ClearRange_Call struct {
65696569 *mock.Call
65706570 }
65716571
6572-// ClearOlderThan is a helper method to define mock.On call
6572+// ClearRange is a helper method to define mock.On call
65736573 // - ctx context.Context
6574-// - before time.Time
6575-func (_e *MockHomepageHistory_Expecter) ClearOlderThan(ctx interface{}, before interface{}) *MockHomepageHistory_ClearOlderThan_Call {
6576- return &MockHomepageHistory_ClearOlderThan_Call{Call: _e.mock.On("ClearOlderThan", ctx, before)}
6574+// - rangeID string
6575+func (_e *MockHomepageHistory_Expecter) ClearRange(ctx interface{}, rangeID interface{}) *MockHomepageHistory_ClearRange_Call {
6576+ return &MockHomepageHistory_ClearRange_Call{Call: _e.mock.On("ClearRange", ctx, rangeID)}
65776577 }
65786578
6579-func (_c *MockHomepageHistory_ClearOlderThan_Call) Run(run func(ctx context.Context, before time.Time)) *MockHomepageHistory_ClearOlderThan_Call {
6579+func (_c *MockHomepageHistory_ClearRange_Call) Run(run func(ctx context.Context, rangeID string)) *MockHomepageHistory_ClearRange_Call {
65806580 _c.Call.Run(func(args mock.Arguments) {
65816581 var arg0 context.Context
65826582 if args[0] != nil {
65836583 arg0 = args[0].(context.Context)
65846584 }
6585- var arg1 time.Time
6585+ var arg1 string
65866586 if args[1] != nil {
6587- arg1 = args[1].(time.Time)
6587+ arg1 = args[1].(string)
65886588 }
65896589 run(
65906590 arg0,
@@ -6594,12 +6594,12 @@ func (_c *MockHomepageHistory_ClearOlderThan_Call) Run(run func(ctx context.Cont
65946594 return _c
65956595 }
65966596
6597-func (_c *MockHomepageHistory_ClearOlderThan_Call) Return(err error) *MockHomepageHistory_ClearOlderThan_Call {
6597+func (_c *MockHomepageHistory_ClearRange_Call) Return(err error) *MockHomepageHistory_ClearRange_Call {
65986598 _c.Call.Return(err)
65996599 return _c
66006600 }
66016601
6602-func (_c *MockHomepageHistory_ClearOlderThan_Call) RunAndReturn(run func(ctx context.Context, before time.Time) error) *MockHomepageHistory_ClearOlderThan_Call {
6602+func (_c *MockHomepageHistory_ClearRange_Call) RunAndReturn(run func(ctx context.Context, rangeID string) error) *MockHomepageHistory_ClearRange_Call {
66036603 _c.Call.Return(run)
66046604 return _c
66056605 }
@@ -6922,6 +6922,86 @@ func (_c *MockHomepageHistory_GetRecent_Call) RunAndReturn(run func(ctx context.
69226922 return _c
69236923 }
69246924
6925+// GetRecentByDomain provides a mock function for the type MockHomepageHistory
6926+func (_mock *MockHomepageHistory) GetRecentByDomain(ctx context.Context, domain string, limit int, offset int) ([]*entity.HistoryEntry, error) {
6927+ ret := _mock.Called(ctx, domain, limit, offset)
6928+
6929+ if len(ret) == 0 {
6930+ panic("no return value specified for GetRecentByDomain")
6931+ }
6932+
6933+ var r0 []*entity.HistoryEntry
6934+ var r1 error
6935+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int, int) ([]*entity.HistoryEntry, error)); ok {
6936+ return returnFunc(ctx, domain, limit, offset)
6937+ }
6938+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int, int) []*entity.HistoryEntry); ok {
6939+ r0 = returnFunc(ctx, domain, limit, offset)
6940+ } else {
6941+ if ret.Get(0) != nil {
6942+ r0 = ret.Get(0).([]*entity.HistoryEntry)
6943+ }
6944+ }
6945+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, int, int) error); ok {
6946+ r1 = returnFunc(ctx, domain, limit, offset)
6947+ } else {
6948+ r1 = ret.Error(1)
6949+ }
6950+ return r0, r1
6951+}
6952+
6953+// MockHomepageHistory_GetRecentByDomain_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRecentByDomain'
6954+type MockHomepageHistory_GetRecentByDomain_Call struct {
6955+ *mock.Call
6956+}
6957+
6958+// GetRecentByDomain is a helper method to define mock.On call
6959+// - ctx context.Context
6960+// - domain string
6961+// - limit int
6962+// - offset int
6963+func (_e *MockHomepageHistory_Expecter) GetRecentByDomain(ctx interface{}, domain interface{}, limit interface{}, offset interface{}) *MockHomepageHistory_GetRecentByDomain_Call {
6964+ return &MockHomepageHistory_GetRecentByDomain_Call{Call: _e.mock.On("GetRecentByDomain", ctx, domain, limit, offset)}
6965+}
6966+
6967+func (_c *MockHomepageHistory_GetRecentByDomain_Call) Run(run func(ctx context.Context, domain string, limit int, offset int)) *MockHomepageHistory_GetRecentByDomain_Call {
6968+ _c.Call.Run(func(args mock.Arguments) {
6969+ var arg0 context.Context
6970+ if args[0] != nil {
6971+ arg0 = args[0].(context.Context)
6972+ }
6973+ var arg1 string
6974+ if args[1] != nil {
6975+ arg1 = args[1].(string)
6976+ }
6977+ var arg2 int
6978+ if args[2] != nil {
6979+ arg2 = args[2].(int)
6980+ }
6981+ var arg3 int
6982+ if args[3] != nil {
6983+ arg3 = args[3].(int)
6984+ }
6985+ run(
6986+ arg0,
6987+ arg1,
6988+ arg2,
6989+ arg3,
6990+ )
6991+ })
6992+ return _c
6993+}
6994+
6995+func (_c *MockHomepageHistory_GetRecentByDomain_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHomepageHistory_GetRecentByDomain_Call {
6996+ _c.Call.Return(historyEntrys, err)
6997+ return _c
6998+}
6999+
7000+func (_c *MockHomepageHistory_GetRecentByDomain_Call) RunAndReturn(run func(ctx context.Context, domain string, limit int, offset int) ([]*entity.HistoryEntry, error)) *MockHomepageHistory_GetRecentByDomain_Call {
7001+ _c.Call.Return(run)
7002+ return _c
7003+}
7004+
69257005 // Search provides a mock function for the type MockHomepageHistory
69267006 func (_mock *MockHomepageHistory) Search(ctx context.Context, input port.HistorySearchInput) (*port.HistorySearchOutput, error) {
69277007 ret := _mock.Called(ctx, input)
@@ -7017,6 +7097,74 @@ func (_m *MockHomepageFavorites) EXPECT() *MockHomepageFavorites_Expecter {
70177097 return &MockHomepageFavorites_Expecter{mock: &_m.Mock}
70187098 }
70197099
7100+// AddFavorite provides a mock function for the type MockHomepageFavorites
7101+func (_mock *MockHomepageFavorites) AddFavorite(ctx context.Context, input port.FavoriteCreateInput) (*entity.Favorite, error) {
7102+ ret := _mock.Called(ctx, input)
7103+
7104+ if len(ret) == 0 {
7105+ panic("no return value specified for AddFavorite")
7106+ }
7107+
7108+ var r0 *entity.Favorite
7109+ var r1 error
7110+ if returnFunc, ok := ret.Get(0).(func(context.Context, port.FavoriteCreateInput) (*entity.Favorite, error)); ok {
7111+ return returnFunc(ctx, input)
7112+ }
7113+ if returnFunc, ok := ret.Get(0).(func(context.Context, port.FavoriteCreateInput) *entity.Favorite); ok {
7114+ r0 = returnFunc(ctx, input)
7115+ } else {
7116+ if ret.Get(0) != nil {
7117+ r0 = ret.Get(0).(*entity.Favorite)
7118+ }
7119+ }
7120+ if returnFunc, ok := ret.Get(1).(func(context.Context, port.FavoriteCreateInput) error); ok {
7121+ r1 = returnFunc(ctx, input)
7122+ } else {
7123+ r1 = ret.Error(1)
7124+ }
7125+ return r0, r1
7126+}
7127+
7128+// MockHomepageFavorites_AddFavorite_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddFavorite'
7129+type MockHomepageFavorites_AddFavorite_Call struct {
7130+ *mock.Call
7131+}
7132+
7133+// AddFavorite is a helper method to define mock.On call
7134+// - ctx context.Context
7135+// - input port.FavoriteCreateInput
7136+func (_e *MockHomepageFavorites_Expecter) AddFavorite(ctx interface{}, input interface{}) *MockHomepageFavorites_AddFavorite_Call {
7137+ return &MockHomepageFavorites_AddFavorite_Call{Call: _e.mock.On("AddFavorite", ctx, input)}
7138+}
7139+
7140+func (_c *MockHomepageFavorites_AddFavorite_Call) Run(run func(ctx context.Context, input port.FavoriteCreateInput)) *MockHomepageFavorites_AddFavorite_Call {
7141+ _c.Call.Run(func(args mock.Arguments) {
7142+ var arg0 context.Context
7143+ if args[0] != nil {
7144+ arg0 = args[0].(context.Context)
7145+ }
7146+ var arg1 port.FavoriteCreateInput
7147+ if args[1] != nil {
7148+ arg1 = args[1].(port.FavoriteCreateInput)
7149+ }
7150+ run(
7151+ arg0,
7152+ arg1,
7153+ )
7154+ })
7155+ return _c
7156+}
7157+
7158+func (_c *MockHomepageFavorites_AddFavorite_Call) Return(favorite *entity.Favorite, err error) *MockHomepageFavorites_AddFavorite_Call {
7159+ _c.Call.Return(favorite, err)
7160+ return _c
7161+}
7162+
7163+func (_c *MockHomepageFavorites_AddFavorite_Call) RunAndReturn(run func(ctx context.Context, input port.FavoriteCreateInput) (*entity.Favorite, error)) *MockHomepageFavorites_AddFavorite_Call {
7164+ _c.Call.Return(run)
7165+ return _c
7166+}
7167+
70207168 // AddTag provides a mock function for the type MockHomepageFavorites
70217169 func (_mock *MockHomepageFavorites) AddTag(ctx context.Context, name string, color string) (*entity.Tag, error) {
70227170 ret := _mock.Called(ctx, name, color)
@@ -7165,6 +7313,63 @@ func (_c *MockHomepageFavorites_CreateFolder_Call) RunAndReturn(run func(ctx con
71657313 return _c
71667314 }
71677315
7316+// DeleteFavorite provides a mock function for the type MockHomepageFavorites
7317+func (_mock *MockHomepageFavorites) DeleteFavorite(ctx context.Context, id entity.FavoriteID) error {
7318+ ret := _mock.Called(ctx, id)
7319+
7320+ if len(ret) == 0 {
7321+ panic("no return value specified for DeleteFavorite")
7322+ }
7323+
7324+ var r0 error
7325+ if returnFunc, ok := ret.Get(0).(func(context.Context, entity.FavoriteID) error); ok {
7326+ r0 = returnFunc(ctx, id)
7327+ } else {
7328+ r0 = ret.Error(0)
7329+ }
7330+ return r0
7331+}
7332+
7333+// MockHomepageFavorites_DeleteFavorite_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteFavorite'
7334+type MockHomepageFavorites_DeleteFavorite_Call struct {
7335+ *mock.Call
7336+}
7337+
7338+// DeleteFavorite is a helper method to define mock.On call
7339+// - ctx context.Context
7340+// - id entity.FavoriteID
7341+func (_e *MockHomepageFavorites_Expecter) DeleteFavorite(ctx interface{}, id interface{}) *MockHomepageFavorites_DeleteFavorite_Call {
7342+ return &MockHomepageFavorites_DeleteFavorite_Call{Call: _e.mock.On("DeleteFavorite", ctx, id)}
7343+}
7344+
7345+func (_c *MockHomepageFavorites_DeleteFavorite_Call) Run(run func(ctx context.Context, id entity.FavoriteID)) *MockHomepageFavorites_DeleteFavorite_Call {
7346+ _c.Call.Run(func(args mock.Arguments) {
7347+ var arg0 context.Context
7348+ if args[0] != nil {
7349+ arg0 = args[0].(context.Context)
7350+ }
7351+ var arg1 entity.FavoriteID
7352+ if args[1] != nil {
7353+ arg1 = args[1].(entity.FavoriteID)
7354+ }
7355+ run(
7356+ arg0,
7357+ arg1,
7358+ )
7359+ })
7360+ return _c
7361+}
7362+
7363+func (_c *MockHomepageFavorites_DeleteFavorite_Call) Return(err error) *MockHomepageFavorites_DeleteFavorite_Call {
7364+ _c.Call.Return(err)
7365+ return _c
7366+}
7367+
7368+func (_c *MockHomepageFavorites_DeleteFavorite_Call) RunAndReturn(run func(ctx context.Context, id entity.FavoriteID) error) *MockHomepageFavorites_DeleteFavorite_Call {
7369+ _c.Call.Return(run)
7370+ return _c
7371+}
7372+
71687373 // DeleteFolder provides a mock function for the type MockHomepageFavorites
71697374 func (_mock *MockHomepageFavorites) DeleteFolder(ctx context.Context, id entity.FolderID) error {
71707375 ret := _mock.Called(ctx, id)
@@ -7785,6 +7990,74 @@ func (_c *MockHomepageFavorites_UntagFavorite_Call) RunAndReturn(run func(ctx co
77857990 return _c
77867991 }
77877992
7993+// UpdateFavorite provides a mock function for the type MockHomepageFavorites
7994+func (_mock *MockHomepageFavorites) UpdateFavorite(ctx context.Context, input port.FavoriteUpdateInput) (*entity.Favorite, error) {
7995+ ret := _mock.Called(ctx, input)
7996+
7997+ if len(ret) == 0 {
7998+ panic("no return value specified for UpdateFavorite")
7999+ }
8000+
8001+ var r0 *entity.Favorite
8002+ var r1 error
8003+ if returnFunc, ok := ret.Get(0).(func(context.Context, port.FavoriteUpdateInput) (*entity.Favorite, error)); ok {
8004+ return returnFunc(ctx, input)
8005+ }
8006+ if returnFunc, ok := ret.Get(0).(func(context.Context, port.FavoriteUpdateInput) *entity.Favorite); ok {
8007+ r0 = returnFunc(ctx, input)
8008+ } else {
8009+ if ret.Get(0) != nil {
8010+ r0 = ret.Get(0).(*entity.Favorite)
8011+ }
8012+ }
8013+ if returnFunc, ok := ret.Get(1).(func(context.Context, port.FavoriteUpdateInput) error); ok {
8014+ r1 = returnFunc(ctx, input)
8015+ } else {
8016+ r1 = ret.Error(1)
8017+ }
8018+ return r0, r1
8019+}
8020+
8021+// MockHomepageFavorites_UpdateFavorite_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateFavorite'
8022+type MockHomepageFavorites_UpdateFavorite_Call struct {
8023+ *mock.Call
8024+}
8025+
8026+// UpdateFavorite is a helper method to define mock.On call
8027+// - ctx context.Context
8028+// - input port.FavoriteUpdateInput
8029+func (_e *MockHomepageFavorites_Expecter) UpdateFavorite(ctx interface{}, input interface{}) *MockHomepageFavorites_UpdateFavorite_Call {
8030+ return &MockHomepageFavorites_UpdateFavorite_Call{Call: _e.mock.On("UpdateFavorite", ctx, input)}
8031+}
8032+
8033+func (_c *MockHomepageFavorites_UpdateFavorite_Call) Run(run func(ctx context.Context, input port.FavoriteUpdateInput)) *MockHomepageFavorites_UpdateFavorite_Call {
8034+ _c.Call.Run(func(args mock.Arguments) {
8035+ var arg0 context.Context
8036+ if args[0] != nil {
8037+ arg0 = args[0].(context.Context)
8038+ }
8039+ var arg1 port.FavoriteUpdateInput
8040+ if args[1] != nil {
8041+ arg1 = args[1].(port.FavoriteUpdateInput)
8042+ }
8043+ run(
8044+ arg0,
8045+ arg1,
8046+ )
8047+ })
8048+ return _c
8049+}
8050+
8051+func (_c *MockHomepageFavorites_UpdateFavorite_Call) Return(favorite *entity.Favorite, err error) *MockHomepageFavorites_UpdateFavorite_Call {
8052+ _c.Call.Return(favorite, err)
8053+ return _c
8054+}
8055+
8056+func (_c *MockHomepageFavorites_UpdateFavorite_Call) RunAndReturn(run func(ctx context.Context, input port.FavoriteUpdateInput) (*entity.Favorite, error)) *MockHomepageFavorites_UpdateFavorite_Call {
8057+ _c.Call.Return(run)
8058+ return _c
8059+}
8060+
77888061 // UpdateFolder provides a mock function for the type MockHomepageFavorites
77898062 func (_mock *MockHomepageFavorites) UpdateFolder(ctx context.Context, id entity.FolderID, name string, icon string) error {
77908063 ret := _mock.Called(ctx, id, name, icon)
@@ -6490,44 +6490,44 @@ func (_c *MockHomepageHistory_ClearAll_Call) RunAndReturn(run func(ctx context.C
6490 return _c6490 return _c
6491 }6491 }
6492 6492
6493-// ClearRange provides a mock function for the type MockHomepageHistory6493+// ClearOlderThan provides a mock function for the type MockHomepageHistory
6494-func (_mock *MockHomepageHistory) ClearRange(ctx context.Context, rangeID string) error {6494+func (_mock *MockHomepageHistory) ClearOlderThan(ctx context.Context, before time.Time) error {
6495- ret := _mock.Called(ctx, rangeID)6495+ ret := _mock.Called(ctx, before)
6496 6496
6497 if len(ret) == 0 {6497 if len(ret) == 0 {
6498- panic("no return value specified for ClearRange")6498+ panic("no return value specified for ClearOlderThan")
6499 }6499 }
6500 6500
6501 var r0 error6501 var r0 error
6502- if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {6502+ if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) error); ok {
6503- r0 = returnFunc(ctx, rangeID)6503+ r0 = returnFunc(ctx, before)
6504 } else {6504 } else {
6505 r0 = ret.Error(0)6505 r0 = ret.Error(0)
6506 }6506 }
6507 return r06507 return r0
6508 }6508 }
6509 6509
6510-// MockHomepageHistory_ClearRange_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearRange'6510+// MockHomepageHistory_ClearOlderThan_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearOlderThan'
6511-type MockHomepageHistory_ClearRange_Call struct {6511+type MockHomepageHistory_ClearOlderThan_Call struct {
6512 *mock.Call6512 *mock.Call
6513 }6513 }
6514 6514
6515-// ClearRange is a helper method to define mock.On call6515+// ClearOlderThan is a helper method to define mock.On call
6516 // - ctx context.Context6516 // - ctx context.Context
6517-// - rangeID string6517+// - before time.Time
6518-func (_e *MockHomepageHistory_Expecter) ClearRange(ctx interface{}, rangeID interface{}) *MockHomepageHistory_ClearRange_Call {6518+func (_e *MockHomepageHistory_Expecter) ClearOlderThan(ctx interface{}, before interface{}) *MockHomepageHistory_ClearOlderThan_Call {
6519- return &MockHomepageHistory_ClearRange_Call{Call: _e.mock.On("ClearRange", ctx, rangeID)}6519+ return &MockHomepageHistory_ClearOlderThan_Call{Call: _e.mock.On("ClearOlderThan", ctx, before)}
6520 }6520 }
6521 6521
6522-func (_c *MockHomepageHistory_ClearRange_Call) Run(run func(ctx context.Context, rangeID string)) *MockHomepageHistory_ClearRange_Call {6522+func (_c *MockHomepageHistory_ClearOlderThan_Call) Run(run func(ctx context.Context, before time.Time)) *MockHomepageHistory_ClearOlderThan_Call {
6523 _c.Call.Run(func(args mock.Arguments) {6523 _c.Call.Run(func(args mock.Arguments) {
6524 var arg0 context.Context6524 var arg0 context.Context
6525 if args[0] != nil {6525 if args[0] != nil {
6526 arg0 = args[0].(context.Context)6526 arg0 = args[0].(context.Context)
6527 }6527 }
6528- var arg1 string6528+ var arg1 time.Time
6529 if args[1] != nil {6529 if args[1] != nil {
6530- arg1 = args[1].(string)6530+ arg1 = args[1].(time.Time)
6531 }6531 }
6532 run(6532 run(
6533 arg0,6533 arg0,
@@ -6537,54 +6537,54 @@ func (_c *MockHomepageHistory_ClearRange_Call) Run(run func(ctx context.Context,
6537 return _c6537 return _c
6538 }6538 }
6539 6539
6540-func (_c *MockHomepageHistory_ClearRange_Call) Return(err error) *MockHomepageHistory_ClearRange_Call {6540+func (_c *MockHomepageHistory_ClearOlderThan_Call) Return(err error) *MockHomepageHistory_ClearOlderThan_Call {
6541 _c.Call.Return(err)6541 _c.Call.Return(err)
6542 return _c6542 return _c
6543 }6543 }
6544 6544
6545-func (_c *MockHomepageHistory_ClearRange_Call) RunAndReturn(run func(ctx context.Context, rangeID string) error) *MockHomepageHistory_ClearRange_Call {6545+func (_c *MockHomepageHistory_ClearOlderThan_Call) RunAndReturn(run func(ctx context.Context, before time.Time) error) *MockHomepageHistory_ClearOlderThan_Call {
6546 _c.Call.Return(run)6546 _c.Call.Return(run)
6547 return _c6547 return _c
6548 }6548 }
6549 6549
6550-// ClearOlderThan provides a mock function for the type MockHomepageHistory6550+// ClearRange provides a mock function for the type MockHomepageHistory
6551-func (_mock *MockHomepageHistory) ClearOlderThan(ctx context.Context, before time.Time) error {6551+func (_mock *MockHomepageHistory) ClearRange(ctx context.Context, rangeID string) error {
6552- ret := _mock.Called(ctx, before)6552+ ret := _mock.Called(ctx, rangeID)
6553 6553
6554 if len(ret) == 0 {6554 if len(ret) == 0 {
6555- panic("no return value specified for ClearOlderThan")6555+ panic("no return value specified for ClearRange")
6556 }6556 }
6557 6557
6558 var r0 error6558 var r0 error
6559- if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) error); ok {6559+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
6560- r0 = returnFunc(ctx, before)6560+ r0 = returnFunc(ctx, rangeID)
6561 } else {6561 } else {
6562 r0 = ret.Error(0)6562 r0 = ret.Error(0)
6563 }6563 }
6564 return r06564 return r0
6565 }6565 }
6566 6566
6567-// MockHomepageHistory_ClearOlderThan_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearOlderThan'6567+// MockHomepageHistory_ClearRange_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearRange'
6568-type MockHomepageHistory_ClearOlderThan_Call struct {6568+type MockHomepageHistory_ClearRange_Call struct {
6569 *mock.Call6569 *mock.Call
6570 }6570 }
6571 6571
6572-// ClearOlderThan is a helper method to define mock.On call6572+// ClearRange is a helper method to define mock.On call
6573 // - ctx context.Context6573 // - ctx context.Context
6574-// - before time.Time6574+// - rangeID string
6575-func (_e *MockHomepageHistory_Expecter) ClearOlderThan(ctx interface{}, before interface{}) *MockHomepageHistory_ClearOlderThan_Call {6575+func (_e *MockHomepageHistory_Expecter) ClearRange(ctx interface{}, rangeID interface{}) *MockHomepageHistory_ClearRange_Call {
6576- return &MockHomepageHistory_ClearOlderThan_Call{Call: _e.mock.On("ClearOlderThan", ctx, before)}6576+ return &MockHomepageHistory_ClearRange_Call{Call: _e.mock.On("ClearRange", ctx, rangeID)}
6577 }6577 }
6578 6578
6579-func (_c *MockHomepageHistory_ClearOlderThan_Call) Run(run func(ctx context.Context, before time.Time)) *MockHomepageHistory_ClearOlderThan_Call {6579+func (_c *MockHomepageHistory_ClearRange_Call) Run(run func(ctx context.Context, rangeID string)) *MockHomepageHistory_ClearRange_Call {
6580 _c.Call.Run(func(args mock.Arguments) {6580 _c.Call.Run(func(args mock.Arguments) {
6581 var arg0 context.Context6581 var arg0 context.Context
6582 if args[0] != nil {6582 if args[0] != nil {
6583 arg0 = args[0].(context.Context)6583 arg0 = args[0].(context.Context)
6584 }6584 }
6585- var arg1 time.Time6585+ var arg1 string
6586 if args[1] != nil {6586 if args[1] != nil {
6587- arg1 = args[1].(time.Time)6587+ arg1 = args[1].(string)
6588 }6588 }
6589 run(6589 run(
6590 arg0,6590 arg0,
@@ -6594,12 +6594,12 @@ func (_c *MockHomepageHistory_ClearOlderThan_Call) Run(run func(ctx context.Cont
6594 return _c6594 return _c
6595 }6595 }
6596 6596
6597-func (_c *MockHomepageHistory_ClearOlderThan_Call) Return(err error) *MockHomepageHistory_ClearOlderThan_Call {6597+func (_c *MockHomepageHistory_ClearRange_Call) Return(err error) *MockHomepageHistory_ClearRange_Call {
6598 _c.Call.Return(err)6598 _c.Call.Return(err)
6599 return _c6599 return _c
6600 }6600 }
6601 6601
6602-func (_c *MockHomepageHistory_ClearOlderThan_Call) RunAndReturn(run func(ctx context.Context, before time.Time) error) *MockHomepageHistory_ClearOlderThan_Call {6602+func (_c *MockHomepageHistory_ClearRange_Call) RunAndReturn(run func(ctx context.Context, rangeID string) error) *MockHomepageHistory_ClearRange_Call {
6603 _c.Call.Return(run)6603 _c.Call.Return(run)
6604 return _c6604 return _c
6605 }6605 }
@@ -6922,6 +6922,86 @@ func (_c *MockHomepageHistory_GetRecent_Call) RunAndReturn(run func(ctx context.
6922 return _c6922 return _c
6923 }6923 }
6924 6924
6925+// GetRecentByDomain provides a mock function for the type MockHomepageHistory
6926+func (_mock *MockHomepageHistory) GetRecentByDomain(ctx context.Context, domain string, limit int, offset int) ([]*entity.HistoryEntry, error) {
6927+ ret := _mock.Called(ctx, domain, limit, offset)
6928+
6929+ if len(ret) == 0 {
6930+ panic("no return value specified for GetRecentByDomain")
6931+ }
6932+
6933+ var r0 []*entity.HistoryEntry
6934+ var r1 error
6935+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int, int) ([]*entity.HistoryEntry, error)); ok {
6936+ return returnFunc(ctx, domain, limit, offset)
6937+ }
6938+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int, int) []*entity.HistoryEntry); ok {
6939+ r0 = returnFunc(ctx, domain, limit, offset)
6940+ } else {
6941+ if ret.Get(0) != nil {
6942+ r0 = ret.Get(0).([]*entity.HistoryEntry)
6943+ }
6944+ }
6945+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, int, int) error); ok {
6946+ r1 = returnFunc(ctx, domain, limit, offset)
6947+ } else {
6948+ r1 = ret.Error(1)
6949+ }
6950+ return r0, r1
6951+}
6952+
6953+// MockHomepageHistory_GetRecentByDomain_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRecentByDomain'
6954+type MockHomepageHistory_GetRecentByDomain_Call struct {
6955+ *mock.Call
6956+}
6957+
6958+// GetRecentByDomain is a helper method to define mock.On call
6959+// - ctx context.Context
6960+// - domain string
6961+// - limit int
6962+// - offset int
6963+func (_e *MockHomepageHistory_Expecter) GetRecentByDomain(ctx interface{}, domain interface{}, limit interface{}, offset interface{}) *MockHomepageHistory_GetRecentByDomain_Call {
6964+ return &MockHomepageHistory_GetRecentByDomain_Call{Call: _e.mock.On("GetRecentByDomain", ctx, domain, limit, offset)}
6965+}
6966+
6967+func (_c *MockHomepageHistory_GetRecentByDomain_Call) Run(run func(ctx context.Context, domain string, limit int, offset int)) *MockHomepageHistory_GetRecentByDomain_Call {
6968+ _c.Call.Run(func(args mock.Arguments) {
6969+ var arg0 context.Context
6970+ if args[0] != nil {
6971+ arg0 = args[0].(context.Context)
6972+ }
6973+ var arg1 string
6974+ if args[1] != nil {
6975+ arg1 = args[1].(string)
6976+ }
6977+ var arg2 int
6978+ if args[2] != nil {
6979+ arg2 = args[2].(int)
6980+ }
6981+ var arg3 int
6982+ if args[3] != nil {
6983+ arg3 = args[3].(int)
6984+ }
6985+ run(
6986+ arg0,
6987+ arg1,
6988+ arg2,
6989+ arg3,
6990+ )
6991+ })
6992+ return _c
6993+}
6994+
6995+func (_c *MockHomepageHistory_GetRecentByDomain_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHomepageHistory_GetRecentByDomain_Call {
6996+ _c.Call.Return(historyEntrys, err)
6997+ return _c
6998+}
6999+
7000+func (_c *MockHomepageHistory_GetRecentByDomain_Call) RunAndReturn(run func(ctx context.Context, domain string, limit int, offset int) ([]*entity.HistoryEntry, error)) *MockHomepageHistory_GetRecentByDomain_Call {
7001+ _c.Call.Return(run)
7002+ return _c
7003+}
7004+
6925 // Search provides a mock function for the type MockHomepageHistory7005 // Search provides a mock function for the type MockHomepageHistory
6926 func (_mock *MockHomepageHistory) Search(ctx context.Context, input port.HistorySearchInput) (*port.HistorySearchOutput, error) {7006 func (_mock *MockHomepageHistory) Search(ctx context.Context, input port.HistorySearchInput) (*port.HistorySearchOutput, error) {
6927 ret := _mock.Called(ctx, input)7007 ret := _mock.Called(ctx, input)
@@ -7017,6 +7097,74 @@ func (_m *MockHomepageFavorites) EXPECT() *MockHomepageFavorites_Expecter {
7017 return &MockHomepageFavorites_Expecter{mock: &_m.Mock}7097 return &MockHomepageFavorites_Expecter{mock: &_m.Mock}
7018 }7098 }
7019 7099
7100+// AddFavorite provides a mock function for the type MockHomepageFavorites
7101+func (_mock *MockHomepageFavorites) AddFavorite(ctx context.Context, input port.FavoriteCreateInput) (*entity.Favorite, error) {
7102+ ret := _mock.Called(ctx, input)
7103+
7104+ if len(ret) == 0 {
7105+ panic("no return value specified for AddFavorite")
7106+ }
7107+
7108+ var r0 *entity.Favorite
7109+ var r1 error
7110+ if returnFunc, ok := ret.Get(0).(func(context.Context, port.FavoriteCreateInput) (*entity.Favorite, error)); ok {
7111+ return returnFunc(ctx, input)
7112+ }
7113+ if returnFunc, ok := ret.Get(0).(func(context.Context, port.FavoriteCreateInput) *entity.Favorite); ok {
7114+ r0 = returnFunc(ctx, input)
7115+ } else {
7116+ if ret.Get(0) != nil {
7117+ r0 = ret.Get(0).(*entity.Favorite)
7118+ }
7119+ }
7120+ if returnFunc, ok := ret.Get(1).(func(context.Context, port.FavoriteCreateInput) error); ok {
7121+ r1 = returnFunc(ctx, input)
7122+ } else {
7123+ r1 = ret.Error(1)
7124+ }
7125+ return r0, r1
7126+}
7127+
7128+// MockHomepageFavorites_AddFavorite_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddFavorite'
7129+type MockHomepageFavorites_AddFavorite_Call struct {
7130+ *mock.Call
7131+}
7132+
7133+// AddFavorite is a helper method to define mock.On call
7134+// - ctx context.Context
7135+// - input port.FavoriteCreateInput
7136+func (_e *MockHomepageFavorites_Expecter) AddFavorite(ctx interface{}, input interface{}) *MockHomepageFavorites_AddFavorite_Call {
7137+ return &MockHomepageFavorites_AddFavorite_Call{Call: _e.mock.On("AddFavorite", ctx, input)}
7138+}
7139+
7140+func (_c *MockHomepageFavorites_AddFavorite_Call) Run(run func(ctx context.Context, input port.FavoriteCreateInput)) *MockHomepageFavorites_AddFavorite_Call {
7141+ _c.Call.Run(func(args mock.Arguments) {
7142+ var arg0 context.Context
7143+ if args[0] != nil {
7144+ arg0 = args[0].(context.Context)
7145+ }
7146+ var arg1 port.FavoriteCreateInput
7147+ if args[1] != nil {
7148+ arg1 = args[1].(port.FavoriteCreateInput)
7149+ }
7150+ run(
7151+ arg0,
7152+ arg1,
7153+ )
7154+ })
7155+ return _c
7156+}
7157+
7158+func (_c *MockHomepageFavorites_AddFavorite_Call) Return(favorite *entity.Favorite, err error) *MockHomepageFavorites_AddFavorite_Call {
7159+ _c.Call.Return(favorite, err)
7160+ return _c
7161+}
7162+
7163+func (_c *MockHomepageFavorites_AddFavorite_Call) RunAndReturn(run func(ctx context.Context, input port.FavoriteCreateInput) (*entity.Favorite, error)) *MockHomepageFavorites_AddFavorite_Call {
7164+ _c.Call.Return(run)
7165+ return _c
7166+}
7167+
7020 // AddTag provides a mock function for the type MockHomepageFavorites7168 // AddTag provides a mock function for the type MockHomepageFavorites
7021 func (_mock *MockHomepageFavorites) AddTag(ctx context.Context, name string, color string) (*entity.Tag, error) {7169 func (_mock *MockHomepageFavorites) AddTag(ctx context.Context, name string, color string) (*entity.Tag, error) {
7022 ret := _mock.Called(ctx, name, color)7170 ret := _mock.Called(ctx, name, color)
@@ -7165,6 +7313,63 @@ func (_c *MockHomepageFavorites_CreateFolder_Call) RunAndReturn(run func(ctx con
7165 return _c7313 return _c
7166 }7314 }
7167 7315
7316+// DeleteFavorite provides a mock function for the type MockHomepageFavorites
7317+func (_mock *MockHomepageFavorites) DeleteFavorite(ctx context.Context, id entity.FavoriteID) error {
7318+ ret := _mock.Called(ctx, id)
7319+
7320+ if len(ret) == 0 {
7321+ panic("no return value specified for DeleteFavorite")
7322+ }
7323+
7324+ var r0 error
7325+ if returnFunc, ok := ret.Get(0).(func(context.Context, entity.FavoriteID) error); ok {
7326+ r0 = returnFunc(ctx, id)
7327+ } else {
7328+ r0 = ret.Error(0)
7329+ }
7330+ return r0
7331+}
7332+
7333+// MockHomepageFavorites_DeleteFavorite_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteFavorite'
7334+type MockHomepageFavorites_DeleteFavorite_Call struct {
7335+ *mock.Call
7336+}
7337+
7338+// DeleteFavorite is a helper method to define mock.On call
7339+// - ctx context.Context
7340+// - id entity.FavoriteID
7341+func (_e *MockHomepageFavorites_Expecter) DeleteFavorite(ctx interface{}, id interface{}) *MockHomepageFavorites_DeleteFavorite_Call {
7342+ return &MockHomepageFavorites_DeleteFavorite_Call{Call: _e.mock.On("DeleteFavorite", ctx, id)}
7343+}
7344+
7345+func (_c *MockHomepageFavorites_DeleteFavorite_Call) Run(run func(ctx context.Context, id entity.FavoriteID)) *MockHomepageFavorites_DeleteFavorite_Call {
7346+ _c.Call.Run(func(args mock.Arguments) {
7347+ var arg0 context.Context
7348+ if args[0] != nil {
7349+ arg0 = args[0].(context.Context)
7350+ }
7351+ var arg1 entity.FavoriteID
7352+ if args[1] != nil {
7353+ arg1 = args[1].(entity.FavoriteID)
7354+ }
7355+ run(
7356+ arg0,
7357+ arg1,
7358+ )
7359+ })
7360+ return _c
7361+}
7362+
7363+func (_c *MockHomepageFavorites_DeleteFavorite_Call) Return(err error) *MockHomepageFavorites_DeleteFavorite_Call {
7364+ _c.Call.Return(err)
7365+ return _c
7366+}
7367+
7368+func (_c *MockHomepageFavorites_DeleteFavorite_Call) RunAndReturn(run func(ctx context.Context, id entity.FavoriteID) error) *MockHomepageFavorites_DeleteFavorite_Call {
7369+ _c.Call.Return(run)
7370+ return _c
7371+}
7372+
7168 // DeleteFolder provides a mock function for the type MockHomepageFavorites7373 // DeleteFolder provides a mock function for the type MockHomepageFavorites
7169 func (_mock *MockHomepageFavorites) DeleteFolder(ctx context.Context, id entity.FolderID) error {7374 func (_mock *MockHomepageFavorites) DeleteFolder(ctx context.Context, id entity.FolderID) error {
7170 ret := _mock.Called(ctx, id)7375 ret := _mock.Called(ctx, id)
@@ -7785,6 +7990,74 @@ func (_c *MockHomepageFavorites_UntagFavorite_Call) RunAndReturn(run func(ctx co
7785 return _c7990 return _c
7786 }7991 }
7787 7992
7993+// UpdateFavorite provides a mock function for the type MockHomepageFavorites
7994+func (_mock *MockHomepageFavorites) UpdateFavorite(ctx context.Context, input port.FavoriteUpdateInput) (*entity.Favorite, error) {
7995+ ret := _mock.Called(ctx, input)
7996+
7997+ if len(ret) == 0 {
7998+ panic("no return value specified for UpdateFavorite")
7999+ }
8000+
8001+ var r0 *entity.Favorite
8002+ var r1 error
8003+ if returnFunc, ok := ret.Get(0).(func(context.Context, port.FavoriteUpdateInput) (*entity.Favorite, error)); ok {
8004+ return returnFunc(ctx, input)
8005+ }
8006+ if returnFunc, ok := ret.Get(0).(func(context.Context, port.FavoriteUpdateInput) *entity.Favorite); ok {
8007+ r0 = returnFunc(ctx, input)
8008+ } else {
8009+ if ret.Get(0) != nil {
8010+ r0 = ret.Get(0).(*entity.Favorite)
8011+ }
8012+ }
8013+ if returnFunc, ok := ret.Get(1).(func(context.Context, port.FavoriteUpdateInput) error); ok {
8014+ r1 = returnFunc(ctx, input)
8015+ } else {
8016+ r1 = ret.Error(1)
8017+ }
8018+ return r0, r1
8019+}
8020+
8021+// MockHomepageFavorites_UpdateFavorite_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateFavorite'
8022+type MockHomepageFavorites_UpdateFavorite_Call struct {
8023+ *mock.Call
8024+}
8025+
8026+// UpdateFavorite is a helper method to define mock.On call
8027+// - ctx context.Context
8028+// - input port.FavoriteUpdateInput
8029+func (_e *MockHomepageFavorites_Expecter) UpdateFavorite(ctx interface{}, input interface{}) *MockHomepageFavorites_UpdateFavorite_Call {
8030+ return &MockHomepageFavorites_UpdateFavorite_Call{Call: _e.mock.On("UpdateFavorite", ctx, input)}
8031+}
8032+
8033+func (_c *MockHomepageFavorites_UpdateFavorite_Call) Run(run func(ctx context.Context, input port.FavoriteUpdateInput)) *MockHomepageFavorites_UpdateFavorite_Call {
8034+ _c.Call.Run(func(args mock.Arguments) {
8035+ var arg0 context.Context
8036+ if args[0] != nil {
8037+ arg0 = args[0].(context.Context)
8038+ }
8039+ var arg1 port.FavoriteUpdateInput
8040+ if args[1] != nil {
8041+ arg1 = args[1].(port.FavoriteUpdateInput)
8042+ }
8043+ run(
8044+ arg0,
8045+ arg1,
8046+ )
8047+ })
8048+ return _c
8049+}
8050+
8051+func (_c *MockHomepageFavorites_UpdateFavorite_Call) Return(favorite *entity.Favorite, err error) *MockHomepageFavorites_UpdateFavorite_Call {
8052+ _c.Call.Return(favorite, err)
8053+ return _c
8054+}
8055+
8056+func (_c *MockHomepageFavorites_UpdateFavorite_Call) RunAndReturn(run func(ctx context.Context, input port.FavoriteUpdateInput) (*entity.Favorite, error)) *MockHomepageFavorites_UpdateFavorite_Call {
8057+ _c.Call.Return(run)
8058+ return _c
8059+}
8060+
7788 // UpdateFolder provides a mock function for the type MockHomepageFavorites8061 // UpdateFolder provides a mock function for the type MockHomepageFavorites
7789 func (_mock *MockHomepageFavorites) UpdateFolder(ctx context.Context, id entity.FolderID, name string, icon string) error {8062 func (_mock *MockHomepageFavorites) UpdateFolder(ctx context.Context, id entity.FolderID, name string, icon string) error {
7790 ret := _mock.Called(ctx, id, name, icon)8063 ret := _mock.Called(ctx, id, name, icon)
M internal/application/port/systemviews.go +1 -0
@@ -9,6 +9,7 @@ import (
99 // SystemviewHistoryService exposes history operations for the systemviews history route.
1010 type SystemviewHistoryService interface {
1111 Timeline(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
12+ TimelineByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error)
1213 Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error)
1314 DeleteEntry(ctx context.Context, id int64) error
1415 DeleteRange(ctx context.Context, rangeID string) error
@@ -9,6 +9,7 @@ import (
9 // SystemviewHistoryService exposes history operations for the systemviews history route.9 // SystemviewHistoryService exposes history operations for the systemviews history route.
10 type SystemviewHistoryService interface {10 type SystemviewHistoryService interface {
11 Timeline(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)11 Timeline(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
12+ TimelineByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error)
12 Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error)13 Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error)
13 DeleteEntry(ctx context.Context, id int64) error14 DeleteEntry(ctx context.Context, id int64) error
14 DeleteRange(ctx context.Context, rangeID string) error15 DeleteRange(ctx context.Context, rangeID string) error
M internal/application/usecase/read_systemview_config_test.go +1 -0
@@ -34,6 +34,7 @@ func TestReadSystemviewConfigUseCase_Default(t *testing.T) {
3434 require.Equal(t, []string{"default"}, reader.calls)
3535 }
3636
37+// Handwritten fake to capture stateful config reads for assertions.
3738 type stubSystemviewConfigReader struct {
3839 current port.SystemviewConfigPayload
3940 defaultPayload port.SystemviewConfigPayload
@@ -34,6 +34,7 @@ func TestReadSystemviewConfigUseCase_Default(t *testing.T) {
34 require.Equal(t, []string{"default"}, reader.calls)34 require.Equal(t, []string{"default"}, reader.calls)
35 }35 }
36 36
37+// Handwritten fake to capture stateful config reads for assertions.
37 type stubSystemviewConfigReader struct {38 type stubSystemviewConfigReader struct {
38 current port.SystemviewConfigPayload39 current port.SystemviewConfigPayload
39 defaultPayload port.SystemviewConfigPayload40 defaultPayload port.SystemviewConfigPayload
M internal/application/usecase/save_webui_config_test.go +2 -0
@@ -95,12 +95,14 @@ func validWebUIConfig() port.WebUIConfig {
9595 }
9696 }
9797
98+// Handwritten fake to preserve stateful save assertions without mock generation.
9899 type fakeWebUIConfigSaver struct{}
99100
100101 func (fakeWebUIConfigSaver) SaveWebUIConfig(context.Context, port.WebUIConfig) error {
101102 return nil
102103 }
103104
105+// Handwritten fake to capture the saved config state for assertions.
104106 type capturingWebUIConfigSaver struct {
105107 saved port.WebUIConfig
106108 }
@@ -95,12 +95,14 @@ func validWebUIConfig() port.WebUIConfig {
95 }95 }
96 }96 }
97 97
98+// Handwritten fake to preserve stateful save assertions without mock generation.
98 type fakeWebUIConfigSaver struct{}99 type fakeWebUIConfigSaver struct{}
99 100
100 func (fakeWebUIConfigSaver) SaveWebUIConfig(context.Context, port.WebUIConfig) error {101 func (fakeWebUIConfigSaver) SaveWebUIConfig(context.Context, port.WebUIConfig) error {
101 return nil102 return nil
102 }103 }
103 104
105+// Handwritten fake to capture the saved config state for assertions.
104 type capturingWebUIConfigSaver struct {106 type capturingWebUIConfigSaver struct {
105 saved port.WebUIConfig107 saved port.WebUIConfig
106 }108 }
M internal/application/usecase/search_history.go +23 -0
@@ -9,6 +9,7 @@ import (
99 "github.com/bnema/dumber/internal/domain/entity"
1010 historydomain "github.com/bnema/dumber/internal/domain/history"
1111 "github.com/bnema/dumber/internal/domain/repository"
12+ domainurl "github.com/bnema/dumber/internal/domain/url"
1213 "github.com/bnema/dumber/internal/logging"
1314 )
1415
@@ -75,6 +76,23 @@ func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int
7576 return entries, nil
7677 }
7778
79+// GetRecentByDomain retrieves recent history entries for a canonical domain with pagination.
80+func (uc *SearchHistoryUseCase) GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) {
81+ if limit <= 0 {
82+ limit = 50
83+ }
84+ domain = domainurl.CanonicalDomain(domain)
85+ if domain == "" {
86+ return nil, fmt.Errorf("domain is required")
87+ }
88+
89+ entries, err := uc.historyRepo.GetRecentByDomain(ctx, domain, limit, offset)
90+ if err != nil {
91+ return nil, fmt.Errorf("failed to get recent history by domain: %w", err)
92+ }
93+ return entries, nil
94+}
95+
7896 // GetRecentSince retrieves history entries visited within the last N days.
7997 // If days is 0, returns all history entries.
8098 // If days is negative, defaults to 30 days.
@@ -196,6 +214,11 @@ func (uc *SearchHistoryUseCase) Delete(ctx context.Context, id int64) error {
196214
197215 // DeleteByDomain removes all history entries for a domain.
198216 func (uc *SearchHistoryUseCase) DeleteByDomain(ctx context.Context, domain string) error {
217+ domain = domainurl.CanonicalDomain(domain)
218+ if domain == "" {
219+ return fmt.Errorf("domain is required")
220+ }
221+
199222 log := logging.FromContext(ctx)
200223 log.Debug().Str("domain", domain).Msg("deleting history by domain")
201224
@@ -9,6 +9,7 @@ import (
9 "github.com/bnema/dumber/internal/domain/entity"9 "github.com/bnema/dumber/internal/domain/entity"
10 historydomain "github.com/bnema/dumber/internal/domain/history"10 historydomain "github.com/bnema/dumber/internal/domain/history"
11 "github.com/bnema/dumber/internal/domain/repository"11 "github.com/bnema/dumber/internal/domain/repository"
12+ domainurl "github.com/bnema/dumber/internal/domain/url"
12 "github.com/bnema/dumber/internal/logging"13 "github.com/bnema/dumber/internal/logging"
13 )14 )
14 15
@@ -75,6 +76,23 @@ func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int
75 return entries, nil76 return entries, nil
76 }77 }
77 78
79+// GetRecentByDomain retrieves recent history entries for a canonical domain with pagination.
80+func (uc *SearchHistoryUseCase) GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) {
81+ if limit <= 0 {
82+ limit = 50
83+ }
84+ domain = domainurl.CanonicalDomain(domain)
85+ if domain == "" {
86+ return nil, fmt.Errorf("domain is required")
87+ }
88+
89+ entries, err := uc.historyRepo.GetRecentByDomain(ctx, domain, limit, offset)
90+ if err != nil {
91+ return nil, fmt.Errorf("failed to get recent history by domain: %w", err)
92+ }
93+ return entries, nil
94+}
95+
78 // GetRecentSince retrieves history entries visited within the last N days.96 // GetRecentSince retrieves history entries visited within the last N days.
79 // If days is 0, returns all history entries.97 // If days is 0, returns all history entries.
80 // If days is negative, defaults to 30 days.98 // If days is negative, defaults to 30 days.
@@ -196,6 +214,11 @@ func (uc *SearchHistoryUseCase) Delete(ctx context.Context, id int64) error {
196 214
197 // DeleteByDomain removes all history entries for a domain.215 // DeleteByDomain removes all history entries for a domain.
198 func (uc *SearchHistoryUseCase) DeleteByDomain(ctx context.Context, domain string) error {216 func (uc *SearchHistoryUseCase) DeleteByDomain(ctx context.Context, domain string) error {
217+ domain = domainurl.CanonicalDomain(domain)
218+ if domain == "" {
219+ return fmt.Errorf("domain is required")
220+ }
221+
199 log := logging.FromContext(ctx)222 log := logging.FromContext(ctx)
200 log.Debug().Str("domain", domain).Msg("deleting history by domain")223 log.Debug().Str("domain", domain).Msg("deleting history by domain")
201 224
M internal/domain/repository/history.go +3 -0
@@ -21,6 +21,9 @@ type HistoryRepository interface {
2121 // GetRecent retrieves recent history entries with pagination.
2222 GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
2323
24+ // GetRecentByDomain retrieves recent history entries for a domain with pagination.
25+ GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error)
26+
2427 // GetRecentSince retrieves history entries visited within the last N days.
2528 // days must be > 0.
2629 GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error)
@@ -21,6 +21,9 @@ type HistoryRepository interface {
21 // GetRecent retrieves recent history entries with pagination.21 // GetRecent retrieves recent history entries with pagination.
22 GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)22 GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
23 23
24+ // GetRecentByDomain retrieves recent history entries for a domain with pagination.
25+ GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error)
26+
24 // GetRecentSince retrieves history entries visited within the last N days.27 // GetRecentSince retrieves history entries visited within the last N days.
25 // days must be > 0.28 // days must be > 0.
26 GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error)29 GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error)
M internal/domain/repository/mocks/mocks.go +80 -0
@@ -2661,6 +2661,86 @@ func (_c *MockHistoryRepository_GetRecent_Call) RunAndReturn(run func(ctx contex
26612661 return _c
26622662 }
26632663
2664+// GetRecentByDomain provides a mock function for the type MockHistoryRepository
2665+func (_mock *MockHistoryRepository) GetRecentByDomain(ctx context.Context, domain string, limit int, offset int) ([]*entity.HistoryEntry, error) {
2666+ ret := _mock.Called(ctx, domain, limit, offset)
2667+
2668+ if len(ret) == 0 {
2669+ panic("no return value specified for GetRecentByDomain")
2670+ }
2671+
2672+ var r0 []*entity.HistoryEntry
2673+ var r1 error
2674+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int, int) ([]*entity.HistoryEntry, error)); ok {
2675+ return returnFunc(ctx, domain, limit, offset)
2676+ }
2677+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int, int) []*entity.HistoryEntry); ok {
2678+ r0 = returnFunc(ctx, domain, limit, offset)
2679+ } else {
2680+ if ret.Get(0) != nil {
2681+ r0 = ret.Get(0).([]*entity.HistoryEntry)
2682+ }
2683+ }
2684+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, int, int) error); ok {
2685+ r1 = returnFunc(ctx, domain, limit, offset)
2686+ } else {
2687+ r1 = ret.Error(1)
2688+ }
2689+ return r0, r1
2690+}
2691+
2692+// MockHistoryRepository_GetRecentByDomain_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRecentByDomain'
2693+type MockHistoryRepository_GetRecentByDomain_Call struct {
2694+ *mock.Call
2695+}
2696+
2697+// GetRecentByDomain is a helper method to define mock.On call
2698+// - ctx context.Context
2699+// - domain string
2700+// - limit int
2701+// - offset int
2702+func (_e *MockHistoryRepository_Expecter) GetRecentByDomain(ctx interface{}, domain interface{}, limit interface{}, offset interface{}) *MockHistoryRepository_GetRecentByDomain_Call {
2703+ return &MockHistoryRepository_GetRecentByDomain_Call{Call: _e.mock.On("GetRecentByDomain", ctx, domain, limit, offset)}
2704+}
2705+
2706+func (_c *MockHistoryRepository_GetRecentByDomain_Call) Run(run func(ctx context.Context, domain string, limit int, offset int)) *MockHistoryRepository_GetRecentByDomain_Call {
2707+ _c.Call.Run(func(args mock.Arguments) {
2708+ var arg0 context.Context
2709+ if args[0] != nil {
2710+ arg0 = args[0].(context.Context)
2711+ }
2712+ var arg1 string
2713+ if args[1] != nil {
2714+ arg1 = args[1].(string)
2715+ }
2716+ var arg2 int
2717+ if args[2] != nil {
2718+ arg2 = args[2].(int)
2719+ }
2720+ var arg3 int
2721+ if args[3] != nil {
2722+ arg3 = args[3].(int)
2723+ }
2724+ run(
2725+ arg0,
2726+ arg1,
2727+ arg2,
2728+ arg3,
2729+ )
2730+ })
2731+ return _c
2732+}
2733+
2734+func (_c *MockHistoryRepository_GetRecentByDomain_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetRecentByDomain_Call {
2735+ _c.Call.Return(historyEntrys, err)
2736+ return _c
2737+}
2738+
2739+func (_c *MockHistoryRepository_GetRecentByDomain_Call) RunAndReturn(run func(ctx context.Context, domain string, limit int, offset int) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecentByDomain_Call {
2740+ _c.Call.Return(run)
2741+ return _c
2742+}
2743+
26642744 // GetRecentSince provides a mock function for the type MockHistoryRepository
26652745 func (_mock *MockHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
26662746 ret := _mock.Called(ctx, days)
@@ -2661,6 +2661,86 @@ func (_c *MockHistoryRepository_GetRecent_Call) RunAndReturn(run func(ctx contex
2661 return _c2661 return _c
2662 }2662 }
2663 2663
2664+// GetRecentByDomain provides a mock function for the type MockHistoryRepository
2665+func (_mock *MockHistoryRepository) GetRecentByDomain(ctx context.Context, domain string, limit int, offset int) ([]*entity.HistoryEntry, error) {
2666+ ret := _mock.Called(ctx, domain, limit, offset)
2667+
2668+ if len(ret) == 0 {
2669+ panic("no return value specified for GetRecentByDomain")
2670+ }
2671+
2672+ var r0 []*entity.HistoryEntry
2673+ var r1 error
2674+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int, int) ([]*entity.HistoryEntry, error)); ok {
2675+ return returnFunc(ctx, domain, limit, offset)
2676+ }
2677+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int, int) []*entity.HistoryEntry); ok {
2678+ r0 = returnFunc(ctx, domain, limit, offset)
2679+ } else {
2680+ if ret.Get(0) != nil {
2681+ r0 = ret.Get(0).([]*entity.HistoryEntry)
2682+ }
2683+ }
2684+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, int, int) error); ok {
2685+ r1 = returnFunc(ctx, domain, limit, offset)
2686+ } else {
2687+ r1 = ret.Error(1)
2688+ }
2689+ return r0, r1
2690+}
2691+
2692+// MockHistoryRepository_GetRecentByDomain_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRecentByDomain'
2693+type MockHistoryRepository_GetRecentByDomain_Call struct {
2694+ *mock.Call
2695+}
2696+
2697+// GetRecentByDomain is a helper method to define mock.On call
2698+// - ctx context.Context
2699+// - domain string
2700+// - limit int
2701+// - offset int
2702+func (_e *MockHistoryRepository_Expecter) GetRecentByDomain(ctx interface{}, domain interface{}, limit interface{}, offset interface{}) *MockHistoryRepository_GetRecentByDomain_Call {
2703+ return &MockHistoryRepository_GetRecentByDomain_Call{Call: _e.mock.On("GetRecentByDomain", ctx, domain, limit, offset)}
2704+}
2705+
2706+func (_c *MockHistoryRepository_GetRecentByDomain_Call) Run(run func(ctx context.Context, domain string, limit int, offset int)) *MockHistoryRepository_GetRecentByDomain_Call {
2707+ _c.Call.Run(func(args mock.Arguments) {
2708+ var arg0 context.Context
2709+ if args[0] != nil {
2710+ arg0 = args[0].(context.Context)
2711+ }
2712+ var arg1 string
2713+ if args[1] != nil {
2714+ arg1 = args[1].(string)
2715+ }
2716+ var arg2 int
2717+ if args[2] != nil {
2718+ arg2 = args[2].(int)
2719+ }
2720+ var arg3 int
2721+ if args[3] != nil {
2722+ arg3 = args[3].(int)
2723+ }
2724+ run(
2725+ arg0,
2726+ arg1,
2727+ arg2,
2728+ arg3,
2729+ )
2730+ })
2731+ return _c
2732+}
2733+
2734+func (_c *MockHistoryRepository_GetRecentByDomain_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetRecentByDomain_Call {
2735+ _c.Call.Return(historyEntrys, err)
2736+ return _c
2737+}
2738+
2739+func (_c *MockHistoryRepository_GetRecentByDomain_Call) RunAndReturn(run func(ctx context.Context, domain string, limit int, offset int) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecentByDomain_Call {
2740+ _c.Call.Return(run)
2741+ return _c
2742+}
2743+
2664 // GetRecentSince provides a mock function for the type MockHistoryRepository2744 // GetRecentSince provides a mock function for the type MockHistoryRepository
2665 func (_mock *MockHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {2745 func (_mock *MockHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
2666 ret := _mock.Called(ctx, days)2746 ret := _mock.Called(ctx, days)
M internal/infrastructure/cef/internal_urls_test.go +0 -10
@@ -18,11 +18,6 @@ func TestToActualInternalURL(t *testing.T) {
1818 in: "dumb://history",
1919 want: "https://dumber.invalid/history",
2020 },
21- {
22- name: "history page root",
23- in: "dumb://history",
24- want: "https://dumber.invalid/history",
25- },
2621 {
2722 name: "error page root",
2823 in: "dumb://error",
@@ -154,11 +149,6 @@ func TestToConceptualInternalURL(t *testing.T) {
154149 in: "https://dumber.invalid/history",
155150 want: "dumb://history",
156151 },
157- {
158- name: "history page root",
159- in: "https://dumber.invalid/history",
160- want: "dumb://history",
161- },
162152 {
163153 name: "error page root",
164154 in: "https://dumber.invalid/error",
@@ -18,11 +18,6 @@ func TestToActualInternalURL(t *testing.T) {
18 in: "dumb://history",18 in: "dumb://history",
19 want: "https://dumber.invalid/history",19 want: "https://dumber.invalid/history",
20 },20 },
21- {
22- name: "history page root",
23- in: "dumb://history",
24- want: "https://dumber.invalid/history",
25- },
26 {21 {
27 name: "error page root",22 name: "error page root",
28 in: "dumb://error",23 in: "dumb://error",
@@ -154,11 +149,6 @@ func TestToConceptualInternalURL(t *testing.T) {
154 in: "https://dumber.invalid/history",149 in: "https://dumber.invalid/history",
155 want: "dumb://history",150 want: "dumb://history",
156 },151 },
157- {
158- name: "history page root",
159- in: "https://dumber.invalid/history",
160- want: "dumb://history",
161- },
162 {152 {
163 name: "error page root",153 name: "error page root",
164 in: "https://dumber.invalid/error",154 in: "https://dumber.invalid/error",
M internal/infrastructure/cef/scheme_handler.go +13 -3
@@ -500,7 +500,7 @@ func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler {
500500 }
501501
502502 fullPath := filepath.ToSlash(filepath.Join(assetDir, relPath))
503- data, err := fs.ReadFile(h.assets, fullPath)
503+ data, headers, err := readAssetWithEncoding(h.assets, fullPath, relPath)
504504 if err != nil {
505505 h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")
506506 return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found")
@@ -513,7 +513,17 @@ func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler {
513513 Int("size", len(data)).
514514 Msg("serving asset")
515515
516- return h.newRawResourceHandler(http.StatusOK, contentType, data)
516+ return newStaticResourceHandler(http.StatusOK, contentType, data, headers)
517+}
518+
519+func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, map[string]string, error) {
520+ if strings.HasSuffix(relPath, ".wasm") {
521+ if data, err := fs.ReadFile(assets, fullPath+".br"); err == nil {
522+ return data, map[string]string{"Content-Encoding": "br", "Vary": "Accept-Encoding"}, nil
523+ }
524+ }
525+ data, err := fs.ReadFile(assets, fullPath)
526+ return data, nil, err
517527 }
518528
519529 // resolveAssetPath maps either a dumb:// URL or the actual internal HTTPS URL
@@ -581,7 +591,7 @@ func resolveActualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
581591 }
582592
583593 if !strings.Contains(path, "/") && strings.Contains(path, ".") {
584- return assetDirForPageHost(historyPath), path, true
594+ return "systemviews", path, true
585595 }
586596
587597 return "", "", false
@@ -500,7 +500,7 @@ func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler {
500 }500 }
501 501
502 fullPath := filepath.ToSlash(filepath.Join(assetDir, relPath))502 fullPath := filepath.ToSlash(filepath.Join(assetDir, relPath))
503- data, err := fs.ReadFile(h.assets, fullPath)503+ data, headers, err := readAssetWithEncoding(h.assets, fullPath, relPath)
504 if err != nil {504 if err != nil {
505 h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")505 h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")
506 return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found")506 return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found")
@@ -513,7 +513,17 @@ func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler {
513 Int("size", len(data)).513 Int("size", len(data)).
514 Msg("serving asset")514 Msg("serving asset")
515 515
516- return h.newRawResourceHandler(http.StatusOK, contentType, data)516+ return newStaticResourceHandler(http.StatusOK, contentType, data, headers)
517+}
518+
519+func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, map[string]string, error) {
520+ if strings.HasSuffix(relPath, ".wasm") {
521+ if data, err := fs.ReadFile(assets, fullPath+".br"); err == nil {
522+ return data, map[string]string{"Content-Encoding": "br", "Vary": "Accept-Encoding"}, nil
523+ }
524+ }
525+ data, err := fs.ReadFile(assets, fullPath)
526+ return data, nil, err
517 }527 }
518 528
519 // resolveAssetPath maps either a dumb:// URL or the actual internal HTTPS URL529 // resolveAssetPath maps either a dumb:// URL or the actual internal HTTPS URL
@@ -581,7 +591,7 @@ func resolveActualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
581 }591 }
582 592
583 if !strings.Contains(path, "/") && strings.Contains(path, ".") {593 if !strings.Contains(path, "/") && strings.Contains(path, ".") {
584- return assetDirForPageHost(historyPath), path, true594+ return "systemviews", path, true
585 }595 }
586 596
587 return "", "", false597 return "", "", false
M internal/infrastructure/config/defaults_test.go +2 -1
@@ -4,6 +4,7 @@ import (
44 "testing"
55
66 "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/require"
78 )
89
910 func TestDefaultConfig_CoreDefaults(t *testing.T) {
@@ -38,6 +39,6 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) {
3839 func requireActionBinding(t *testing.T, actions map[string]ActionBinding, action string, keys []string) {
3940 t.Helper()
4041 binding, ok := actions[action]
41- assert.True(t, ok, "missing action binding %q", action)
42+ require.True(t, ok, "missing action binding %q", action)
4243 assert.Equal(t, keys, binding.Keys)
4344 }
@@ -4,6 +4,7 @@ import (
4 "testing"4 "testing"
5 5
6 "github.com/stretchr/testify/assert"6 "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/require"
7 )8 )
8 9
9 func TestDefaultConfig_CoreDefaults(t *testing.T) {10 func TestDefaultConfig_CoreDefaults(t *testing.T) {
@@ -38,6 +39,6 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) {
38 func requireActionBinding(t *testing.T, actions map[string]ActionBinding, action string, keys []string) {39 func requireActionBinding(t *testing.T, actions map[string]ActionBinding, action string, keys []string) {
39 t.Helper()40 t.Helper()
40 binding, ok := actions[action]41 binding, ok := actions[action]
41- assert.True(t, ok, "missing action binding %q", action)42+ require.True(t, ok, "missing action binding %q", action)
42 assert.Equal(t, keys, binding.Keys)43 assert.Equal(t, keys, binding.Keys)
43 }44 }
M internal/infrastructure/config/systemview_reader.go +1 -1
@@ -31,7 +31,7 @@ func (r *systemviewConfigReader) build(ctx context.Context, cfg *Config) (port.S
3131 }
3232
3333 var hw *port.HardwareInfo
34- if r != nil && r.hwSurveyor != nil {
34+ if r.hwSurveyor != nil {
3535 survey := r.hwSurveyor.Survey(ctx)
3636 hw = &survey
3737 }
@@ -31,7 +31,7 @@ func (r *systemviewConfigReader) build(ctx context.Context, cfg *Config) (port.S
31 }31 }
32 32
33 var hw *port.HardwareInfo33 var hw *port.HardwareInfo
34- if r != nil && r.hwSurveyor != nil {34+ if r.hwSurveyor != nil {
35 survey := r.hwSurveyor.Survey(ctx)35 survey := r.hwSurveyor.Survey(ctx)
36 hw = &survey36 hw = &survey
37 }37 }
M internal/infrastructure/handlers/homepage/history.go +33 -0
@@ -26,6 +26,13 @@ type timelineRequest struct {
2626 Offset int `json:"offset"`
2727 }
2828
29+type timelineByDomainRequest struct {
30+ RequestID string `json:"requestId"`
31+ Domain string `json:"domain"`
32+ Limit int `json:"limit"`
33+ Offset int `json:"offset"`
34+}
35+
2936 // HandleTimeline handles history_timeline messages.
3037 func (h *HistoryHandlers) HandleTimeline() port.WebUIMessageHandler {
3138 return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
@@ -51,6 +58,32 @@ func (h *HistoryHandlers) HandleTimeline() port.WebUIMessageHandler {
5158 })
5259 }
5360
61+// HandleTimelineByDomain handles history_timeline_by_domain messages.
62+func (h *HistoryHandlers) HandleTimelineByDomain() port.WebUIMessageHandler {
63+ return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
64+ log := logging.FromContext(ctx)
65+
66+ var req timelineByDomainRequest
67+ if err := json.Unmarshal(payload, &req); err != nil {
68+ return NewErrorResponse("", err), nil
69+ }
70+
71+ log.Debug().
72+ Str("request_id", req.RequestID).
73+ Str("domain", req.Domain).
74+ Int("limit", req.Limit).
75+ Int("offset", req.Offset).
76+ Msg("handling history_timeline_by_domain")
77+
78+ entries, err := h.historyUC.GetRecentByDomain(ctx, req.Domain, req.Limit, req.Offset)
79+ if err != nil {
80+ return NewErrorResponse(req.RequestID, err), nil
81+ }
82+
83+ return NewSuccessResponse(req.RequestID, entries), nil
84+ })
85+}
86+
5487 // searchRequest is the payload for history_search_fts messages.
5588 type searchRequest struct {
5689 RequestID string `json:"requestId"`
@@ -26,6 +26,13 @@ type timelineRequest struct {
26 Offset int `json:"offset"`26 Offset int `json:"offset"`
27 }27 }
28 28
29+type timelineByDomainRequest struct {
30+ RequestID string `json:"requestId"`
31+ Domain string `json:"domain"`
32+ Limit int `json:"limit"`
33+ Offset int `json:"offset"`
34+}
35+
29 // HandleTimeline handles history_timeline messages.36 // HandleTimeline handles history_timeline messages.
30 func (h *HistoryHandlers) HandleTimeline() port.WebUIMessageHandler {37 func (h *HistoryHandlers) HandleTimeline() port.WebUIMessageHandler {
31 return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {38 return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
@@ -51,6 +58,32 @@ func (h *HistoryHandlers) HandleTimeline() port.WebUIMessageHandler {
51 })58 })
52 }59 }
53 60
61+// HandleTimelineByDomain handles history_timeline_by_domain messages.
62+func (h *HistoryHandlers) HandleTimelineByDomain() port.WebUIMessageHandler {
63+ return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
64+ log := logging.FromContext(ctx)
65+
66+ var req timelineByDomainRequest
67+ if err := json.Unmarshal(payload, &req); err != nil {
68+ return NewErrorResponse("", err), nil
69+ }
70+
71+ log.Debug().
72+ Str("request_id", req.RequestID).
73+ Str("domain", req.Domain).
74+ Int("limit", req.Limit).
75+ Int("offset", req.Offset).
76+ Msg("handling history_timeline_by_domain")
77+
78+ entries, err := h.historyUC.GetRecentByDomain(ctx, req.Domain, req.Limit, req.Offset)
79+ if err != nil {
80+ return NewErrorResponse(req.RequestID, err), nil
81+ }
82+
83+ return NewSuccessResponse(req.RequestID, entries), nil
84+ })
85+}
86+
54 // searchRequest is the payload for history_search_fts messages.87 // searchRequest is the payload for history_search_fts messages.
55 type searchRequest struct {88 type searchRequest struct {
56 RequestID string `json:"requestId"`89 RequestID string `json:"requestId"`
M internal/infrastructure/handlers/homepage/registry.go +1 -0
@@ -29,6 +29,7 @@ func RegisterHandlers(ctx context.Context, router port.WebUIHandlerRouter, cfg C
2929 // History handlers
3030 historyHandlers := NewHistoryHandlers(cfg.HistoryUC)
3131 handlers["history_timeline"] = historyHandlers.HandleTimeline()
32+ handlers["history_timeline_by_domain"] = historyHandlers.HandleTimelineByDomain()
3233 handlers["history_search_fts"] = historyHandlers.HandleSearchFTS()
3334 handlers["history_delete_entry"] = historyHandlers.HandleDeleteEntry()
3435 handlers["history_delete_range"] = historyHandlers.HandleDeleteRange()
@@ -29,6 +29,7 @@ func RegisterHandlers(ctx context.Context, router port.WebUIHandlerRouter, cfg C
29 // History handlers29 // History handlers
30 historyHandlers := NewHistoryHandlers(cfg.HistoryUC)30 historyHandlers := NewHistoryHandlers(cfg.HistoryUC)
31 handlers["history_timeline"] = historyHandlers.HandleTimeline()31 handlers["history_timeline"] = historyHandlers.HandleTimeline()
32+ handlers["history_timeline_by_domain"] = historyHandlers.HandleTimelineByDomain()
32 handlers["history_search_fts"] = historyHandlers.HandleSearchFTS()33 handlers["history_search_fts"] = historyHandlers.HandleSearchFTS()
33 handlers["history_delete_entry"] = historyHandlers.HandleDeleteEntry()34 handlers["history_delete_entry"] = historyHandlers.HandleDeleteEntry()
34 handlers["history_delete_range"] = historyHandlers.HandleDeleteRange()35 handlers["history_delete_range"] = historyHandlers.HandleDeleteRange()
M internal/infrastructure/persistence/sqlite/favorite_repo.go +35 -15
@@ -14,12 +14,13 @@ import (
1414 // ==================== Favorite Repository ====================
1515
1616 type favoriteRepo struct {
17+ db *sql.DB
1718 queries *sqlc.Queries
1819 }
1920
2021 // NewFavoriteRepository creates a new SQLite-backed favorite repository.
2122 func NewFavoriteRepository(db *sql.DB) repository.FavoriteRepository {
22- return &favoriteRepo{queries: sqlc.New(db)}
23+ return &favoriteRepo{db: db, queries: sqlc.New(db)}
2324 }
2425
2526 func (r *favoriteRepo) Save(ctx context.Context, fav *entity.Favorite) error {
@@ -32,20 +33,7 @@ func (r *favoriteRepo) Save(ctx context.Context, fav *entity.Favorite) error {
3233 }
3334
3435 if fav.ID > 0 {
35- if err := r.queries.UpdateFavorite(ctx, sqlc.UpdateFavoriteParams{
36- Title: sql.NullString{String: fav.Title, Valid: fav.Title != ""},
37- FaviconUrl: sql.NullString{String: fav.FaviconURL, Valid: fav.FaviconURL != ""},
38- ID: int64(fav.ID),
39- }); err != nil {
40- return err
41- }
42- if err := r.queries.SetFavoriteFolder(ctx, sqlc.SetFavoriteFolderParams{FolderID: folderID, ID: int64(fav.ID)}); err != nil {
43- return err
44- }
45- return r.queries.SetFavoriteShortcut(ctx, sqlc.SetFavoriteShortcutParams{
46- ShortcutKey: sql.NullInt64{Int64: int64Value(fav.ShortcutKey), Valid: fav.ShortcutKey != nil},
47- ID: int64(fav.ID),
48- })
36+ return r.updateExisting(ctx, fav, folderID)
4937 }
5038
5139 row, err := r.queries.CreateFavorite(ctx, sqlc.CreateFavoriteParams{
@@ -61,6 +49,38 @@ func (r *favoriteRepo) Save(ctx context.Context, fav *entity.Favorite) error {
6149 return nil
6250 }
6351
52+func (r *favoriteRepo) updateExisting(ctx context.Context, fav *entity.Favorite, folderID sql.NullInt64) error {
53+ log := logging.FromContext(ctx)
54+ tx, err := r.db.BeginTx(ctx, nil)
55+ if err != nil {
56+ return err
57+ }
58+ defer func() {
59+ if rollbackErr := tx.Rollback(); rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) {
60+ log.Debug().Err(rollbackErr).Msg("favorite update rollback reported non-terminal error")
61+ }
62+ }()
63+
64+ queries := r.queries.WithTx(tx)
65+ if err := queries.UpdateFavorite(ctx, sqlc.UpdateFavoriteParams{
66+ Title: sql.NullString{String: fav.Title, Valid: fav.Title != ""},
67+ FaviconUrl: sql.NullString{String: fav.FaviconURL, Valid: fav.FaviconURL != ""},
68+ ID: int64(fav.ID),
69+ }); err != nil {
70+ return err
71+ }
72+ if err := queries.SetFavoriteFolder(ctx, sqlc.SetFavoriteFolderParams{FolderID: folderID, ID: int64(fav.ID)}); err != nil {
73+ return err
74+ }
75+ if err := queries.SetFavoriteShortcut(ctx, sqlc.SetFavoriteShortcutParams{
76+ ShortcutKey: sql.NullInt64{Int64: int64Value(fav.ShortcutKey), Valid: fav.ShortcutKey != nil},
77+ ID: int64(fav.ID),
78+ }); err != nil {
79+ return err
80+ }
81+ return tx.Commit()
82+}
83+
6484 func int64Value(value *int) int64 {
6585 if value == nil {
6686 return 0
@@ -14,12 +14,13 @@ import (
14 // ==================== Favorite Repository ====================14 // ==================== Favorite Repository ====================
15 15
16 type favoriteRepo struct {16 type favoriteRepo struct {
17+ db *sql.DB
17 queries *sqlc.Queries18 queries *sqlc.Queries
18 }19 }
19 20
20 // NewFavoriteRepository creates a new SQLite-backed favorite repository.21 // NewFavoriteRepository creates a new SQLite-backed favorite repository.
21 func NewFavoriteRepository(db *sql.DB) repository.FavoriteRepository {22 func NewFavoriteRepository(db *sql.DB) repository.FavoriteRepository {
22- return &favoriteRepo{queries: sqlc.New(db)}23+ return &favoriteRepo{db: db, queries: sqlc.New(db)}
23 }24 }
24 25
25 func (r *favoriteRepo) Save(ctx context.Context, fav *entity.Favorite) error {26 func (r *favoriteRepo) Save(ctx context.Context, fav *entity.Favorite) error {
@@ -32,20 +33,7 @@ func (r *favoriteRepo) Save(ctx context.Context, fav *entity.Favorite) error {
32 }33 }
33 34
34 if fav.ID > 0 {35 if fav.ID > 0 {
35- if err := r.queries.UpdateFavorite(ctx, sqlc.UpdateFavoriteParams{36+ return r.updateExisting(ctx, fav, folderID)
36- Title: sql.NullString{String: fav.Title, Valid: fav.Title != ""},
37- FaviconUrl: sql.NullString{String: fav.FaviconURL, Valid: fav.FaviconURL != ""},
38- ID: int64(fav.ID),
39- }); err != nil {
40- return err
41- }
42- if err := r.queries.SetFavoriteFolder(ctx, sqlc.SetFavoriteFolderParams{FolderID: folderID, ID: int64(fav.ID)}); err != nil {
43- return err
44- }
45- return r.queries.SetFavoriteShortcut(ctx, sqlc.SetFavoriteShortcutParams{
46- ShortcutKey: sql.NullInt64{Int64: int64Value(fav.ShortcutKey), Valid: fav.ShortcutKey != nil},
47- ID: int64(fav.ID),
48- })
49 }37 }
50 38
51 row, err := r.queries.CreateFavorite(ctx, sqlc.CreateFavoriteParams{39 row, err := r.queries.CreateFavorite(ctx, sqlc.CreateFavoriteParams{
@@ -61,6 +49,38 @@ func (r *favoriteRepo) Save(ctx context.Context, fav *entity.Favorite) error {
61 return nil49 return nil
62 }50 }
63 51
52+func (r *favoriteRepo) updateExisting(ctx context.Context, fav *entity.Favorite, folderID sql.NullInt64) error {
53+ log := logging.FromContext(ctx)
54+ tx, err := r.db.BeginTx(ctx, nil)
55+ if err != nil {
56+ return err
57+ }
58+ defer func() {
59+ if rollbackErr := tx.Rollback(); rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) {
60+ log.Debug().Err(rollbackErr).Msg("favorite update rollback reported non-terminal error")
61+ }
62+ }()
63+
64+ queries := r.queries.WithTx(tx)
65+ if err := queries.UpdateFavorite(ctx, sqlc.UpdateFavoriteParams{
66+ Title: sql.NullString{String: fav.Title, Valid: fav.Title != ""},
67+ FaviconUrl: sql.NullString{String: fav.FaviconURL, Valid: fav.FaviconURL != ""},
68+ ID: int64(fav.ID),
69+ }); err != nil {
70+ return err
71+ }
72+ if err := queries.SetFavoriteFolder(ctx, sqlc.SetFavoriteFolderParams{FolderID: folderID, ID: int64(fav.ID)}); err != nil {
73+ return err
74+ }
75+ if err := queries.SetFavoriteShortcut(ctx, sqlc.SetFavoriteShortcutParams{
76+ ShortcutKey: sql.NullInt64{Int64: int64Value(fav.ShortcutKey), Valid: fav.ShortcutKey != nil},
77+ ID: int64(fav.ID),
78+ }); err != nil {
79+ return err
80+ }
81+ return tx.Commit()
82+}
83+
64 func int64Value(value *int) int64 {84 func int64Value(value *int) int64 {
65 if value == nil {85 if value == nil {
66 return 086 return 0
M internal/infrastructure/persistence/sqlite/history_repo.go +25 -10
@@ -488,12 +488,23 @@ func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*enti
488488 if err != nil {
489489 return nil, err
490490 }
491+ return historyEntriesFromRows(rows), nil
492+}
491493
492- entries := make([]*entity.HistoryEntry, len(rows))
493- for i := range rows {
494- entries[i] = historyFromRow(rows[i])
494+func (r *historyRepo) GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) {
495+ domain = domainurl.CanonicalDomain(domain)
496+ if domain == "" {
497+ return []*entity.HistoryEntry{}, nil
495498 }
496- return entries, nil
499+ rows, err := r.queries.GetRecentHistoryByDomain(ctx, sqlc.GetRecentHistoryByDomainParams{
500+ Domain: sql.NullString{String: domain, Valid: true},
501+ Limit: int64(limit),
502+ Offset: int64(offset),
503+ })
504+ if err != nil {
505+ return nil, err
506+ }
507+ return historyEntriesFromRows(rows), nil
497508 }
498509
499510 func (r *historyRepo) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
@@ -593,12 +604,8 @@ func (r *historyRepo) DeleteAll(ctx context.Context) error {
593604 }
594605
595606 func (r *historyRepo) DeleteByDomain(ctx context.Context, domain string) error {
596- return r.queries.DeleteHistoryByDomain(ctx, sqlc.DeleteHistoryByDomainParams{
597- Column1: sql.NullString{String: domain, Valid: true},
598- Column2: sql.NullString{String: domain, Valid: true},
599- Column3: sql.NullString{String: domain, Valid: true},
600- Column4: sql.NullString{String: domain, Valid: true},
601- })
607+ domain = domainurl.CanonicalDomain(domain)
608+ return r.queries.DeleteHistoryByDomain(ctx, sql.NullString{String: domain, Valid: domain != ""})
602609 }
603610
604611 func (r *historyRepo) GetStats(ctx context.Context) (*entity.HistoryStats, error) {
@@ -694,6 +701,14 @@ func (r *historyRepo) GetDailyVisitCount(ctx context.Context, daysAgo string) ([
694701 return counts, nil
695702 }
696703
704+func historyEntriesFromRows(rows []sqlc.History) []*entity.HistoryEntry {
705+ entries := make([]*entity.HistoryEntry, len(rows))
706+ for i := range rows {
707+ entries[i] = historyFromRow(rows[i])
708+ }
709+ return entries
710+}
711+
697712 func historyFromRow(row sqlc.History) *entity.HistoryEntry {
698713 return &entity.HistoryEntry{
699714 ID: row.ID,
@@ -488,12 +488,23 @@ func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*enti
488 if err != nil {488 if err != nil {
489 return nil, err489 return nil, err
490 }490 }
491+ return historyEntriesFromRows(rows), nil
492+}
491 493
492- entries := make([]*entity.HistoryEntry, len(rows))494+func (r *historyRepo) GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) {
493- for i := range rows {495+ domain = domainurl.CanonicalDomain(domain)
494- entries[i] = historyFromRow(rows[i])496+ if domain == "" {
497+ return []*entity.HistoryEntry{}, nil
495 }498 }
496- return entries, nil499+ rows, err := r.queries.GetRecentHistoryByDomain(ctx, sqlc.GetRecentHistoryByDomainParams{
500+ Domain: sql.NullString{String: domain, Valid: true},
501+ Limit: int64(limit),
502+ Offset: int64(offset),
503+ })
504+ if err != nil {
505+ return nil, err
506+ }
507+ return historyEntriesFromRows(rows), nil
497 }508 }
498 509
499 func (r *historyRepo) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {510 func (r *historyRepo) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
@@ -593,12 +604,8 @@ func (r *historyRepo) DeleteAll(ctx context.Context) error {
593 }604 }
594 605
595 func (r *historyRepo) DeleteByDomain(ctx context.Context, domain string) error {606 func (r *historyRepo) DeleteByDomain(ctx context.Context, domain string) error {
596- return r.queries.DeleteHistoryByDomain(ctx, sqlc.DeleteHistoryByDomainParams{607+ domain = domainurl.CanonicalDomain(domain)
597- Column1: sql.NullString{String: domain, Valid: true},608+ return r.queries.DeleteHistoryByDomain(ctx, sql.NullString{String: domain, Valid: domain != ""})
598- Column2: sql.NullString{String: domain, Valid: true},
599- Column3: sql.NullString{String: domain, Valid: true},
600- Column4: sql.NullString{String: domain, Valid: true},
601- })
602 }609 }
603 610
604 func (r *historyRepo) GetStats(ctx context.Context) (*entity.HistoryStats, error) {611 func (r *historyRepo) GetStats(ctx context.Context) (*entity.HistoryStats, error) {
@@ -694,6 +701,14 @@ func (r *historyRepo) GetDailyVisitCount(ctx context.Context, daysAgo string) ([
694 return counts, nil701 return counts, nil
695 }702 }
696 703
704+func historyEntriesFromRows(rows []sqlc.History) []*entity.HistoryEntry {
705+ entries := make([]*entity.HistoryEntry, len(rows))
706+ for i := range rows {
707+ entries[i] = historyFromRow(rows[i])
708+ }
709+ return entries
710+}
711+
697 func historyFromRow(row sqlc.History) *entity.HistoryEntry {712 func historyFromRow(row sqlc.History) *entity.HistoryEntry {
698 return &entity.HistoryEntry{713 return &entity.HistoryEntry{
699 ID: row.ID,714 ID: row.ID,
M internal/infrastructure/persistence/sqlite/lazy_repos.go +7 -0
@@ -149,6 +149,13 @@ func (r *LazyHistoryRepository) GetRecent(ctx context.Context, limit, offset int
149149 return r.repo.GetRecent(ctx, limit, offset)
150150 }
151151
152+func (r *LazyHistoryRepository) GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) {
153+ if err := r.init(ctx); err != nil {
154+ return nil, err
155+ }
156+ return r.repo.GetRecentByDomain(ctx, domain, limit, offset)
157+}
158+
152159 func (r *LazyHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
153160 if err := r.init(ctx); err != nil {
154161 return nil, err
@@ -149,6 +149,13 @@ func (r *LazyHistoryRepository) GetRecent(ctx context.Context, limit, offset int
149 return r.repo.GetRecent(ctx, limit, offset)149 return r.repo.GetRecent(ctx, limit, offset)
150 }150 }
151 151
152+func (r *LazyHistoryRepository) GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) {
153+ if err := r.init(ctx); err != nil {
154+ return nil, err
155+ }
156+ return r.repo.GetRecentByDomain(ctx, domain, limit, offset)
157+}
158+
152 func (r *LazyHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {159 func (r *LazyHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
153 if err := r.init(ctx); err != nil {160 if err := r.init(ctx); err != nil {
154 return nil, err161 return nil, err
M internal/infrastructure/persistence/sqlite/queries/history.sql +22 -1
@@ -1,6 +1,19 @@
11 -- name: GetRecentHistory :many
22 SELECT * FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ?;
33
4+-- name: GetRecentHistoryByDomain :many
5+SELECT * FROM history
6+WHERE url LIKE '%://' || @domain || '/%'
7+ OR url LIKE '%://' || @domain || '?%'
8+ OR url LIKE '%://' || @domain || '#%'
9+ OR url LIKE '%://' || @domain
10+ OR url LIKE '%://www.' || @domain || '/%'
11+ OR url LIKE '%://www.' || @domain || '?%'
12+ OR url LIKE '%://www.' || @domain || '#%'
13+ OR url LIKE '%://www.' || @domain
14+ORDER BY last_visited DESC
15+LIMIT @limit OFFSET @offset;
16+
417 -- name: GetHistoryByURL :one
518 SELECT * FROM history WHERE url = ? LIMIT 1;
619
@@ -52,7 +65,15 @@ SELECT CAST(strftime('%H', last_visited) AS INTEGER) as hour, COUNT(*) as visit_
5265 SELECT date(last_visited) as day, COUNT(*) as entries, SUM(visit_count) as visits FROM history WHERE last_visited >= date('now', ?) GROUP BY day ORDER BY day ASC;
5366
5467 -- name: DeleteHistoryByDomain :exec
55-DELETE FROM history WHERE url LIKE '%://' || ? || '/%' OR url LIKE '%://' || ? || '?%' OR url LIKE '%://' || ? || '#%' OR url LIKE '%://' || ?;
68+DELETE FROM history
69+WHERE url LIKE '%://' || @domain || '/%'
70+ OR url LIKE '%://' || @domain || '?%'
71+ OR url LIKE '%://' || @domain || '#%'
72+ OR url LIKE '%://' || @domain
73+ OR url LIKE '%://www.' || @domain || '/%'
74+ OR url LIKE '%://www.' || @domain || '?%'
75+ OR url LIKE '%://www.' || @domain || '#%'
76+ OR url LIKE '%://www.' || @domain;
5677
5778 -- name: SearchHistoryFTSUrl :many
5879 SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at
@@ -1,6 +1,19 @@
1 -- name: GetRecentHistory :many1 -- name: GetRecentHistory :many
2 SELECT * FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ?;2 SELECT * FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ?;
3 3
4+-- name: GetRecentHistoryByDomain :many
5+SELECT * FROM history
6+WHERE url LIKE '%://' || @domain || '/%'
7+ OR url LIKE '%://' || @domain || '?%'
8+ OR url LIKE '%://' || @domain || '#%'
9+ OR url LIKE '%://' || @domain
10+ OR url LIKE '%://www.' || @domain || '/%'
11+ OR url LIKE '%://www.' || @domain || '?%'
12+ OR url LIKE '%://www.' || @domain || '#%'
13+ OR url LIKE '%://www.' || @domain
14+ORDER BY last_visited DESC
15+LIMIT @limit OFFSET @offset;
16+
4 -- name: GetHistoryByURL :one17 -- name: GetHistoryByURL :one
5 SELECT * FROM history WHERE url = ? LIMIT 1;18 SELECT * FROM history WHERE url = ? LIMIT 1;
6 19
@@ -52,7 +65,15 @@ SELECT CAST(strftime('%H', last_visited) AS INTEGER) as hour, COUNT(*) as visit_
52 SELECT date(last_visited) as day, COUNT(*) as entries, SUM(visit_count) as visits FROM history WHERE last_visited >= date('now', ?) GROUP BY day ORDER BY day ASC;65 SELECT date(last_visited) as day, COUNT(*) as entries, SUM(visit_count) as visits FROM history WHERE last_visited >= date('now', ?) GROUP BY day ORDER BY day ASC;
53 66
54 -- name: DeleteHistoryByDomain :exec67 -- name: DeleteHistoryByDomain :exec
55-DELETE FROM history WHERE url LIKE '%://' || ? || '/%' OR url LIKE '%://' || ? || '?%' OR url LIKE '%://' || ? || '#%' OR url LIKE '%://' || ?;68+DELETE FROM history
69+WHERE url LIKE '%://' || @domain || '/%'
70+ OR url LIKE '%://' || @domain || '?%'
71+ OR url LIKE '%://' || @domain || '#%'
72+ OR url LIKE '%://' || @domain
73+ OR url LIKE '%://www.' || @domain || '/%'
74+ OR url LIKE '%://www.' || @domain || '?%'
75+ OR url LIKE '%://www.' || @domain || '#%'
76+ OR url LIKE '%://www.' || @domain;
56 77
57 -- name: SearchHistoryFTSUrl :many78 -- name: SearchHistoryFTSUrl :many
58 SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at79 SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at
M internal/infrastructure/persistence/sqlite/sqlc/history.sql.go +62 -15
@@ -35,23 +35,19 @@ func (q *Queries) DeleteAllHistory(ctx context.Context) error {
3535 }
3636
3737 const DeleteHistoryByDomain = `-- name: DeleteHistoryByDomain :exec
38-DELETE FROM history WHERE url LIKE '%://' || ? || '/%' OR url LIKE '%://' || ? || '?%' OR url LIKE '%://' || ? || '#%' OR url LIKE '%://' || ?
38+DELETE FROM history
39+WHERE url LIKE '%://' || ?1 || '/%'
40+ OR url LIKE '%://' || ?1 || '?%'
41+ OR url LIKE '%://' || ?1 || '#%'
42+ OR url LIKE '%://' || ?1
43+ OR url LIKE '%://www.' || ?1 || '/%'
44+ OR url LIKE '%://www.' || ?1 || '?%'
45+ OR url LIKE '%://www.' || ?1 || '#%'
46+ OR url LIKE '%://www.' || ?1
3947 `
4048
41-type DeleteHistoryByDomainParams struct {
42- Column1 sql.NullString `json:"column_1"`
43- Column2 sql.NullString `json:"column_2"`
44- Column3 sql.NullString `json:"column_3"`
45- Column4 sql.NullString `json:"column_4"`
46-}
47-
48-func (q *Queries) DeleteHistoryByDomain(ctx context.Context, arg DeleteHistoryByDomainParams) error {
49- _, err := q.db.ExecContext(ctx, DeleteHistoryByDomain,
50- arg.Column1,
51- arg.Column2,
52- arg.Column3,
53- arg.Column4,
54- )
49+func (q *Queries) DeleteHistoryByDomain(ctx context.Context, domain sql.NullString) error {
50+ _, err := q.db.ExecContext(ctx, DeleteHistoryByDomain, domain)
5551 return err
5652 }
5753
@@ -362,6 +358,57 @@ func (q *Queries) GetRecentHistory(ctx context.Context, arg GetRecentHistoryPara
362358 return items, nil
363359 }
364360
361+const GetRecentHistoryByDomain = `-- name: GetRecentHistoryByDomain :many
362+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history
363+WHERE url LIKE '%://' || ?1 || '/%'
364+ OR url LIKE '%://' || ?1 || '?%'
365+ OR url LIKE '%://' || ?1 || '#%'
366+ OR url LIKE '%://' || ?1
367+ OR url LIKE '%://www.' || ?1 || '/%'
368+ OR url LIKE '%://www.' || ?1 || '?%'
369+ OR url LIKE '%://www.' || ?1 || '#%'
370+ OR url LIKE '%://www.' || ?1
371+ORDER BY last_visited DESC
372+LIMIT ?3 OFFSET ?2
373+`
374+
375+type GetRecentHistoryByDomainParams struct {
376+ Domain sql.NullString `json:"domain"`
377+ Offset int64 `json:"offset"`
378+ Limit int64 `json:"limit"`
379+}
380+
381+func (q *Queries) GetRecentHistoryByDomain(ctx context.Context, arg GetRecentHistoryByDomainParams) ([]History, error) {
382+ rows, err := q.db.QueryContext(ctx, GetRecentHistoryByDomain, arg.Domain, arg.Offset, arg.Limit)
383+ if err != nil {
384+ return nil, err
385+ }
386+ defer rows.Close()
387+ items := []History{}
388+ for rows.Next() {
389+ var i History
390+ if err := rows.Scan(
391+ &i.ID,
392+ &i.Url,
393+ &i.Title,
394+ &i.FaviconUrl,
395+ &i.VisitCount,
396+ &i.LastVisited,
397+ &i.CreatedAt,
398+ ); err != nil {
399+ return nil, err
400+ }
401+ items = append(items, i)
402+ }
403+ if err := rows.Close(); err != nil {
404+ return nil, err
405+ }
406+ if err := rows.Err(); err != nil {
407+ return nil, err
408+ }
409+ return items, nil
410+}
411+
365412 const GetRecentHistorySince = `-- name: GetRecentHistorySince :many
366413 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history
367414 WHERE last_visited >= datetime('now', ?)
@@ -35,23 +35,19 @@ func (q *Queries) DeleteAllHistory(ctx context.Context) error {
35 }35 }
36 36
37 const DeleteHistoryByDomain = `-- name: DeleteHistoryByDomain :exec37 const DeleteHistoryByDomain = `-- name: DeleteHistoryByDomain :exec
38-DELETE FROM history WHERE url LIKE '%://' || ? || '/%' OR url LIKE '%://' || ? || '?%' OR url LIKE '%://' || ? || '#%' OR url LIKE '%://' || ?38+DELETE FROM history
39+WHERE url LIKE '%://' || ?1 || '/%'
40+ OR url LIKE '%://' || ?1 || '?%'
41+ OR url LIKE '%://' || ?1 || '#%'
42+ OR url LIKE '%://' || ?1
43+ OR url LIKE '%://www.' || ?1 || '/%'
44+ OR url LIKE '%://www.' || ?1 || '?%'
45+ OR url LIKE '%://www.' || ?1 || '#%'
46+ OR url LIKE '%://www.' || ?1
39 `47 `
40 48
41-type DeleteHistoryByDomainParams struct {49+func (q *Queries) DeleteHistoryByDomain(ctx context.Context, domain sql.NullString) error {
42- Column1 sql.NullString `json:"column_1"`50+ _, err := q.db.ExecContext(ctx, DeleteHistoryByDomain, domain)
43- Column2 sql.NullString `json:"column_2"`
44- Column3 sql.NullString `json:"column_3"`
45- Column4 sql.NullString `json:"column_4"`
46-}
47-
48-func (q *Queries) DeleteHistoryByDomain(ctx context.Context, arg DeleteHistoryByDomainParams) error {
49- _, err := q.db.ExecContext(ctx, DeleteHistoryByDomain,
50- arg.Column1,
51- arg.Column2,
52- arg.Column3,
53- arg.Column4,
54- )
55 return err51 return err
56 }52 }
57 53
@@ -362,6 +358,57 @@ func (q *Queries) GetRecentHistory(ctx context.Context, arg GetRecentHistoryPara
362 return items, nil358 return items, nil
363 }359 }
364 360
361+const GetRecentHistoryByDomain = `-- name: GetRecentHistoryByDomain :many
362+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history
363+WHERE url LIKE '%://' || ?1 || '/%'
364+ OR url LIKE '%://' || ?1 || '?%'
365+ OR url LIKE '%://' || ?1 || '#%'
366+ OR url LIKE '%://' || ?1
367+ OR url LIKE '%://www.' || ?1 || '/%'
368+ OR url LIKE '%://www.' || ?1 || '?%'
369+ OR url LIKE '%://www.' || ?1 || '#%'
370+ OR url LIKE '%://www.' || ?1
371+ORDER BY last_visited DESC
372+LIMIT ?3 OFFSET ?2
373+`
374+
375+type GetRecentHistoryByDomainParams struct {
376+ Domain sql.NullString `json:"domain"`
377+ Offset int64 `json:"offset"`
378+ Limit int64 `json:"limit"`
379+}
380+
381+func (q *Queries) GetRecentHistoryByDomain(ctx context.Context, arg GetRecentHistoryByDomainParams) ([]History, error) {
382+ rows, err := q.db.QueryContext(ctx, GetRecentHistoryByDomain, arg.Domain, arg.Offset, arg.Limit)
383+ if err != nil {
384+ return nil, err
385+ }
386+ defer rows.Close()
387+ items := []History{}
388+ for rows.Next() {
389+ var i History
390+ if err := rows.Scan(
391+ &i.ID,
392+ &i.Url,
393+ &i.Title,
394+ &i.FaviconUrl,
395+ &i.VisitCount,
396+ &i.LastVisited,
397+ &i.CreatedAt,
398+ ); err != nil {
399+ return nil, err
400+ }
401+ items = append(items, i)
402+ }
403+ if err := rows.Close(); err != nil {
404+ return nil, err
405+ }
406+ if err := rows.Err(); err != nil {
407+ return nil, err
408+ }
409+ return items, nil
410+}
411+
365 const GetRecentHistorySince = `-- name: GetRecentHistorySince :many412 const GetRecentHistorySince = `-- name: GetRecentHistorySince :many
366 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history413 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history
367 WHERE last_visited >= datetime('now', ?)414 WHERE last_visited >= datetime('now', ?)
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +2 -1
@@ -21,7 +21,7 @@ type Querier interface {
2121 DeleteExitedSessionsBefore(ctx context.Context, endedAt sql.NullTime) (int64, error)
2222 DeleteFavorite(ctx context.Context, id int64) error
2323 DeleteFolder(ctx context.Context, id int64) error
24- DeleteHistoryByDomain(ctx context.Context, arg DeleteHistoryByDomainParams) error
24+ DeleteHistoryByDomain(ctx context.Context, domain sql.NullString) error
2525 DeleteHistoryByID(ctx context.Context, id int64) error
2626 DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error
2727 // Deletes exited browser sessions beyond the keep limit, keeping the most recent ones.
@@ -54,6 +54,7 @@ type Querier interface {
5454 GetMostVisited(ctx context.Context, datetime interface{}) ([]History, error)
5555 GetPermission(ctx context.Context, arg GetPermissionParams) (Permission, error)
5656 GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error)
57+ GetRecentHistoryByDomain(ctx context.Context, arg GetRecentHistoryByDomainParams) ([]History, error)
5758 GetRecentHistorySince(ctx context.Context, datetime interface{}) ([]History, error)
5859 GetRecentSessions(ctx context.Context, limit int64) ([]Session, error)
5960 GetRootFolders(ctx context.Context) ([]FavoriteFolder, error)
@@ -21,7 +21,7 @@ type Querier interface {
21 DeleteExitedSessionsBefore(ctx context.Context, endedAt sql.NullTime) (int64, error)21 DeleteExitedSessionsBefore(ctx context.Context, endedAt sql.NullTime) (int64, error)
22 DeleteFavorite(ctx context.Context, id int64) error22 DeleteFavorite(ctx context.Context, id int64) error
23 DeleteFolder(ctx context.Context, id int64) error23 DeleteFolder(ctx context.Context, id int64) error
24- DeleteHistoryByDomain(ctx context.Context, arg DeleteHistoryByDomainParams) error24+ DeleteHistoryByDomain(ctx context.Context, domain sql.NullString) error
25 DeleteHistoryByID(ctx context.Context, id int64) error25 DeleteHistoryByID(ctx context.Context, id int64) error
26 DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error26 DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error
27 // Deletes exited browser sessions beyond the keep limit, keeping the most recent ones.27 // Deletes exited browser sessions beyond the keep limit, keeping the most recent ones.
@@ -54,6 +54,7 @@ type Querier interface {
54 GetMostVisited(ctx context.Context, datetime interface{}) ([]History, error)54 GetMostVisited(ctx context.Context, datetime interface{}) ([]History, error)
55 GetPermission(ctx context.Context, arg GetPermissionParams) (Permission, error)55 GetPermission(ctx context.Context, arg GetPermissionParams) (Permission, error)
56 GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error)56 GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error)
57+ GetRecentHistoryByDomain(ctx context.Context, arg GetRecentHistoryByDomainParams) ([]History, error)
57 GetRecentHistorySince(ctx context.Context, datetime interface{}) ([]History, error)58 GetRecentHistorySince(ctx context.Context, datetime interface{}) ([]History, error)
58 GetRecentSessions(ctx context.Context, limit int64) ([]Session, error)59 GetRecentSessions(ctx context.Context, limit int64) ([]Session, error)
59 GetRootFolders(ctx context.Context) ([]FavoriteFolder, error)60 GetRootFolders(ctx context.Context) ([]FavoriteFolder, error)
M internal/infrastructure/systemviewsbridge/browser_transport_js.go +29 -9
@@ -9,6 +9,7 @@ import (
99 "errors"
1010 "fmt"
1111 "strings"
12+ "sync"
1213 "syscall/js"
1314 )
1415
@@ -23,7 +24,7 @@ func NewBrowserClient() *Client {
2324
2425 func callbackPlanForMessage(msgType string) (callbackPlan, bool) {
2526 switch msgType {
26- case "history_timeline", "history_search_fts", "history_delete_entry", "history_delete_range", "history_analytics",
27+ case "history_timeline", "history_timeline_by_domain", "history_search_fts", "history_delete_entry", "history_delete_range", "history_analytics",
2728 "history_domain_stats", "history_delete_domain", "favorite_list", "favorite_create", "favorite_update", "favorite_delete", "folder_list", "tag_list",
2829 "favorite_set_shortcut", "favorite_set_folder", "folder_create", "folder_update", "folder_delete",
2930 "tag_create", "tag_update", "tag_delete", "tag_assign", "tag_remove":
@@ -123,36 +124,55 @@ func (*webkitTransport) Send(ctx context.Context, body []byte) ([]byte, error) {
123124 err error
124125 }
125126 resultCh := make(chan result, 1)
127+ var callbackMu sync.Mutex
128+ callbacksActive := true
129+ publish := func(res result) {
130+ callbackMu.Lock()
131+ active := callbacksActive
132+ callbackMu.Unlock()
133+ if !active {
134+ return
135+ }
136+ select {
137+ case resultCh <- res:
138+ default:
139+ }
140+ }
126141
127142 successFn := js.FuncOf(func(_ js.Value, args []js.Value) any {
128143 payload, err := stringifyArgs(args)
129144 if err != nil {
130- resultCh <- result{err: err}
145+ publish(result{err: err})
131146 return nil
132147 }
133148 normalized, err := normalizeNativeSuccessResponse(req.requestID, payload)
134- resultCh <- result{body: normalized, err: err}
149+ publish(result{body: normalized, err: err})
135150 return nil
136151 })
137152 errorFn := js.FuncOf(func(_ js.Value, args []js.Value) any {
138153 payload, err := stringifyArgs(args)
139154 if err != nil {
140- resultCh <- result{err: err}
155+ publish(result{err: err})
141156 return nil
142157 }
143158 normalized, err := normalizeNativeErrorResponse(req.requestID, payload)
144- resultCh <- result{body: normalized, err: err}
159+ publish(result{body: normalized, err: err})
145160 return nil
146161 })
147- defer successFn.Release()
148- defer errorFn.Release()
149162
150163 previousSuccess := window.Get(plan.success)
151164 previousError := window.Get(plan.failure)
152165 window.Set(plan.success, successFn)
153166 window.Set(plan.failure, errorFn)
154- defer window.Set(plan.success, previousSuccess)
155- defer window.Set(plan.failure, previousError)
167+ defer func() {
168+ callbackMu.Lock()
169+ callbacksActive = false
170+ callbackMu.Unlock()
171+ window.Set(plan.success, previousSuccess)
172+ window.Set(plan.failure, previousError)
173+ successFn.Release()
174+ errorFn.Release()
175+ }()
156176
157177 handler.Call("postMessage", js.Global().Get("JSON").Call("parse", string(req.body)))
158178
@@ -9,6 +9,7 @@ import (
9 "errors"9 "errors"
10 "fmt"10 "fmt"
11 "strings"11 "strings"
12+ "sync"
12 "syscall/js"13 "syscall/js"
13 )14 )
14 15
@@ -23,7 +24,7 @@ func NewBrowserClient() *Client {
23 24
24 func callbackPlanForMessage(msgType string) (callbackPlan, bool) {25 func callbackPlanForMessage(msgType string) (callbackPlan, bool) {
25 switch msgType {26 switch msgType {
26- case "history_timeline", "history_search_fts", "history_delete_entry", "history_delete_range", "history_analytics",27+ case "history_timeline", "history_timeline_by_domain", "history_search_fts", "history_delete_entry", "history_delete_range", "history_analytics",
27 "history_domain_stats", "history_delete_domain", "favorite_list", "favorite_create", "favorite_update", "favorite_delete", "folder_list", "tag_list",28 "history_domain_stats", "history_delete_domain", "favorite_list", "favorite_create", "favorite_update", "favorite_delete", "folder_list", "tag_list",
28 "favorite_set_shortcut", "favorite_set_folder", "folder_create", "folder_update", "folder_delete",29 "favorite_set_shortcut", "favorite_set_folder", "folder_create", "folder_update", "folder_delete",
29 "tag_create", "tag_update", "tag_delete", "tag_assign", "tag_remove":30 "tag_create", "tag_update", "tag_delete", "tag_assign", "tag_remove":
@@ -123,36 +124,55 @@ func (*webkitTransport) Send(ctx context.Context, body []byte) ([]byte, error) {
123 err error124 err error
124 }125 }
125 resultCh := make(chan result, 1)126 resultCh := make(chan result, 1)
127+ var callbackMu sync.Mutex
128+ callbacksActive := true
129+ publish := func(res result) {
130+ callbackMu.Lock()
131+ active := callbacksActive
132+ callbackMu.Unlock()
133+ if !active {
134+ return
135+ }
136+ select {
137+ case resultCh <- res:
138+ default:
139+ }
140+ }
126 141
127 successFn := js.FuncOf(func(_ js.Value, args []js.Value) any {142 successFn := js.FuncOf(func(_ js.Value, args []js.Value) any {
128 payload, err := stringifyArgs(args)143 payload, err := stringifyArgs(args)
129 if err != nil {144 if err != nil {
130- resultCh <- result{err: err}145+ publish(result{err: err})
131 return nil146 return nil
132 }147 }
133 normalized, err := normalizeNativeSuccessResponse(req.requestID, payload)148 normalized, err := normalizeNativeSuccessResponse(req.requestID, payload)
134- resultCh <- result{body: normalized, err: err}149+ publish(result{body: normalized, err: err})
135 return nil150 return nil
136 })151 })
137 errorFn := js.FuncOf(func(_ js.Value, args []js.Value) any {152 errorFn := js.FuncOf(func(_ js.Value, args []js.Value) any {
138 payload, err := stringifyArgs(args)153 payload, err := stringifyArgs(args)
139 if err != nil {154 if err != nil {
140- resultCh <- result{err: err}155+ publish(result{err: err})
141 return nil156 return nil
142 }157 }
143 normalized, err := normalizeNativeErrorResponse(req.requestID, payload)158 normalized, err := normalizeNativeErrorResponse(req.requestID, payload)
144- resultCh <- result{body: normalized, err: err}159+ publish(result{body: normalized, err: err})
145 return nil160 return nil
146 })161 })
147- defer successFn.Release()
148- defer errorFn.Release()
149 162
150 previousSuccess := window.Get(plan.success)163 previousSuccess := window.Get(plan.success)
151 previousError := window.Get(plan.failure)164 previousError := window.Get(plan.failure)
152 window.Set(plan.success, successFn)165 window.Set(plan.success, successFn)
153 window.Set(plan.failure, errorFn)166 window.Set(plan.failure, errorFn)
154- defer window.Set(plan.success, previousSuccess)167+ defer func() {
155- defer window.Set(plan.failure, previousError)168+ callbackMu.Lock()
169+ callbacksActive = false
170+ callbackMu.Unlock()
171+ window.Set(plan.success, previousSuccess)
172+ window.Set(plan.failure, previousError)
173+ successFn.Release()
174+ errorFn.Release()
175+ }()
156 176
157 handler.Call("postMessage", js.Global().Get("JSON").Call("parse", string(req.body)))177 handler.Call("postMessage", js.Global().Get("JSON").Call("parse", string(req.body)))
158 178
M internal/infrastructure/systemviewsbridge/client.go +10 -1
@@ -64,6 +64,15 @@ func (c *Client) Timeline(ctx context.Context, limit, offset int) ([]*entity.His
6464 }{RequestID: nextRequestID(), Limit: limit, Offset: offset})
6565 }
6666
67+func (c *Client) TimelineByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) {
68+ return request[[]*entity.HistoryEntry](c, ctx, "history_timeline_by_domain", struct {
69+ RequestID string `json:"requestId"`
70+ Domain string `json:"domain"`
71+ Limit int `json:"limit"`
72+ Offset int `json:"offset"`
73+ }{RequestID: nextRequestID(), Domain: domain, Limit: limit, Offset: offset})
74+}
75+
6776 func (c *Client) Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error) {
6877 return request[[]*entity.HistoryEntry](c, ctx, "history_search_fts", struct {
6978 RequestID string `json:"requestId"`
@@ -346,7 +355,7 @@ func (c *Client) transport() Transport {
346355 if c.native != nil && c.native.Available() {
347356 return c.native
348357 }
349- if c.fetch != nil {
358+ if c.fetch != nil && c.fetch.Available() {
350359 return c.fetch
351360 }
352361 return nil
@@ -64,6 +64,15 @@ func (c *Client) Timeline(ctx context.Context, limit, offset int) ([]*entity.His
64 }{RequestID: nextRequestID(), Limit: limit, Offset: offset})64 }{RequestID: nextRequestID(), Limit: limit, Offset: offset})
65 }65 }
66 66
67+func (c *Client) TimelineByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) {
68+ return request[[]*entity.HistoryEntry](c, ctx, "history_timeline_by_domain", struct {
69+ RequestID string `json:"requestId"`
70+ Domain string `json:"domain"`
71+ Limit int `json:"limit"`
72+ Offset int `json:"offset"`
73+ }{RequestID: nextRequestID(), Domain: domain, Limit: limit, Offset: offset})
74+}
75+
67 func (c *Client) Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error) {76 func (c *Client) Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error) {
68 return request[[]*entity.HistoryEntry](c, ctx, "history_search_fts", struct {77 return request[[]*entity.HistoryEntry](c, ctx, "history_search_fts", struct {
69 RequestID string `json:"requestId"`78 RequestID string `json:"requestId"`
@@ -346,7 +355,7 @@ func (c *Client) transport() Transport {
346 if c.native != nil && c.native.Available() {355 if c.native != nil && c.native.Available() {
347 return c.native356 return c.native
348 }357 }
349- if c.fetch != nil {358+ if c.fetch != nil && c.fetch.Available() {
350 return c.fetch359 return c.fetch
351 }360 }
352 return nil361 return nil
M internal/infrastructure/webkit/scheme_handler.go +21 -3
@@ -44,6 +44,7 @@ type SchemeResponse struct {
4444 Data []byte
4545 ContentType string
4646 StatusCode int
47+ Headers map[string]string
4748 }
4849
4950 // PageHandler generates content for a specific page path.
@@ -299,9 +300,8 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
299300 assetDir = h.assetDir
300301 }
301302
302- // Read the asset from embedded FS
303303 fullPath := filepath.ToSlash(filepath.Join(assetDir, relPath))
304- data, err := fs.ReadFile(h.assets, fullPath)
304+ data, headers, err := readAssetWithEncoding(h.assets, fullPath, relPath)
305305 if err != nil {
306306 h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")
307307 return nil
@@ -318,9 +318,20 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
318318 Data: data,
319319 ContentType: contentType,
320320 StatusCode: http.StatusOK,
321+ Headers: headers,
321322 }
322323 }
323324
325+func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, map[string]string, error) {
326+ if strings.HasSuffix(relPath, ".wasm") {
327+ if data, err := fs.ReadFile(assets, fullPath+".br"); err == nil {
328+ return data, map[string]string{"Content-Encoding": "br", "Vary": "Accept-Encoding"}, nil
329+ }
330+ }
331+ data, err := fs.ReadFile(assets, fullPath)
332+ return data, nil, err
333+}
334+
324335 func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
325336 if u == nil {
326337 return "", "", false
@@ -407,7 +418,14 @@ func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response
407418
408419 // WebKit can treat custom schemes as CORS-relevant even for same-origin fetch().
409420 // Add CORS headers to fetch-backed endpoints, including the wasm runtime asset.
410- if headers := responseHeadersForPath(req.GetPath(), contentType); len(headers) > 0 {
421+ headers := responseHeadersForPath(req.GetPath(), contentType)
422+ for name, value := range response.Headers {
423+ if headers == nil {
424+ headers = map[string]string{}
425+ }
426+ headers[name] = value
427+ }
428+ if len(headers) > 0 {
411429 hdrs := soup.NewMessageHeaders(soup.MessageHeadersResponseValue)
412430 for name, value := range headers {
413431 hdrs.Append(name, value)
@@ -44,6 +44,7 @@ type SchemeResponse struct {
44 Data []byte44 Data []byte
45 ContentType string45 ContentType string
46 StatusCode int46 StatusCode int
47+ Headers map[string]string
47 }48 }
48 49
49 // PageHandler generates content for a specific page path.50 // PageHandler generates content for a specific page path.
@@ -299,9 +300,8 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
299 assetDir = h.assetDir300 assetDir = h.assetDir
300 }301 }
301 302
302- // Read the asset from embedded FS
303 fullPath := filepath.ToSlash(filepath.Join(assetDir, relPath))303 fullPath := filepath.ToSlash(filepath.Join(assetDir, relPath))
304- data, err := fs.ReadFile(h.assets, fullPath)304+ data, headers, err := readAssetWithEncoding(h.assets, fullPath, relPath)
305 if err != nil {305 if err != nil {
306 h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")306 h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")
307 return nil307 return nil
@@ -318,9 +318,20 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
318 Data: data,318 Data: data,
319 ContentType: contentType,319 ContentType: contentType,
320 StatusCode: http.StatusOK,320 StatusCode: http.StatusOK,
321+ Headers: headers,
321 }322 }
322 }323 }
323 324
325+func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, map[string]string, error) {
326+ if strings.HasSuffix(relPath, ".wasm") {
327+ if data, err := fs.ReadFile(assets, fullPath+".br"); err == nil {
328+ return data, map[string]string{"Content-Encoding": "br", "Vary": "Accept-Encoding"}, nil
329+ }
330+ }
331+ data, err := fs.ReadFile(assets, fullPath)
332+ return data, nil, err
333+}
334+
324 func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {335 func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
325 if u == nil {336 if u == nil {
326 return "", "", false337 return "", "", false
@@ -407,7 +418,14 @@ func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response
407 418
408 // WebKit can treat custom schemes as CORS-relevant even for same-origin fetch().419 // WebKit can treat custom schemes as CORS-relevant even for same-origin fetch().
409 // Add CORS headers to fetch-backed endpoints, including the wasm runtime asset.420 // Add CORS headers to fetch-backed endpoints, including the wasm runtime asset.
410- if headers := responseHeadersForPath(req.GetPath(), contentType); len(headers) > 0 {421+ headers := responseHeadersForPath(req.GetPath(), contentType)
422+ for name, value := range response.Headers {
423+ if headers == nil {
424+ headers = map[string]string{}
425+ }
426+ headers[name] = value
427+ }
428+ if len(headers) > 0 {
411 hdrs := soup.NewMessageHeaders(soup.MessageHeadersResponseValue)429 hdrs := soup.NewMessageHeaders(soup.MessageHeadersResponseValue)
412 for name, value := range headers {430 for name, value := range headers {
413 hdrs.Append(name, value)431 hdrs.Append(name, value)
M internal/ui/systemviews/app.go +73 -7
@@ -3,6 +3,7 @@ package systemviews
33 import (
44 "context"
55 "errors"
6+ "fmt"
67 "strings"
78 "sync"
89
@@ -44,6 +45,8 @@ type App struct {
4445 configError string
4546 renderedHTML string
4647 actionQueue chan DOMAction
48+ actionCtx context.Context
49+ actionCancel context.CancelFunc
4750 }
4851
4952 const historyTimelineLimit = 25
@@ -53,12 +56,19 @@ func NewApp(deps Dependencies) *App {
5356 }
5457
5558 func (a *App) Run() error {
59+ return a.RunWithContext(context.Background())
60+}
61+
62+func (a *App) RunWithContext(ctx context.Context) error {
5663 if a == nil {
5764 return errors.New("app is nil")
5865 }
66+ if ctx == nil {
67+ ctx = context.Background()
68+ }
5969
6070 a.currentRoute = ParseRoute(a.deps.LocationURI)
61- if err := a.LoadInitial(context.Background()); err != nil {
71+ if err := a.LoadInitial(ctx); err != nil {
6272 return err
6373 }
6474 if a.deps.DOM == nil {
@@ -69,21 +79,43 @@ func (a *App) Run() error {
6979 return err
7080 }
7181 if binder, ok := a.deps.DOM.(DOMActionBinder); ok {
72- return a.bindDOMActions(context.Background(), binder)
82+ return a.bindDOMActions(ctx, binder)
7383 }
7484 return nil
7585 }
7686
7787 func (a *App) bindDOMActions(ctx context.Context, binder DOMActionBinder) error {
7888 if a.actionQueue == nil {
89+ workerCtx, cancel := context.WithCancel(ctx)
7990 a.actionQueue = make(chan DOMAction, 64)
80- go a.runActionWorker(ctx)
91+ a.actionCtx = workerCtx
92+ a.actionCancel = cancel
93+ go a.runActionWorker(workerCtx)
8194 }
8295 return binder.BindActions(func(action DOMAction) {
83- a.actionQueue <- action
96+ if !a.enqueueDOMAction(action) {
97+ go a.surfaceActionError(fmt.Errorf("systemview is busy; dropped action %q", action.Action))
98+ }
8499 })
85100 }
86101
102+func (a *App) enqueueDOMAction(action DOMAction) bool {
103+ if a == nil || a.actionQueue == nil {
104+ return false
105+ }
106+ if a.actionCtx == nil {
107+ return false
108+ }
109+ select {
110+ case <-a.actionCtx.Done():
111+ return false
112+ case a.actionQueue <- action:
113+ return true
114+ default:
115+ return false
116+ }
117+}
118+
87119 func (a *App) runActionWorker(ctx context.Context) {
88120 for {
89121 select {
@@ -93,11 +125,38 @@ func (a *App) runActionWorker(ctx context.Context) {
93125 if !ok {
94126 return
95127 }
96- _ = a.HandleDOMAction(ctx, action)
128+ if err := a.HandleDOMAction(ctx, action); err != nil {
129+ a.surfaceActionError(err)
130+ }
97131 }
98132 }
99133 }
100134
135+func (a *App) surfaceActionError(err error) {
136+ if a == nil || err == nil {
137+ return
138+ }
139+ a.mu.Lock()
140+ a.renderRouteError(err)
141+ a.mu.Unlock()
142+ _ = a.mountRenderedHTML()
143+}
144+
145+func (a *App) Close() {
146+ if a == nil {
147+ return
148+ }
149+ if a.actionCancel != nil {
150+ a.actionCancel()
151+ a.actionCancel = nil
152+ }
153+ a.actionCtx = nil
154+ a.actionQueue = nil
155+ if releaser, ok := a.deps.DOM.(interface{ Release() }); ok {
156+ releaser.Release()
157+ }
158+}
159+
101160 func (a *App) LoadInitial(ctx context.Context) error {
102161 if a == nil {
103162 return errors.New("app is nil")
@@ -182,7 +241,6 @@ func (a *App) loadHistoryRoute(ctx context.Context) error {
182241 a.favorites = nil
183242 a.folders = nil
184243 a.tags = nil
185- entries = filterHistoryEntriesByDomain(entries, a.historyDomainFilter)
186244 a.historyEntries = entries
187245 a.historyAnalytics = analytics
188246 a.historyDomainStats = domains
@@ -206,8 +264,16 @@ func (a *App) loadHistoryRoute(ctx context.Context) error {
206264
207265 func (a *App) loadHistoryEntries(ctx context.Context) ([]*entity.HistoryEntry, error) {
208266 query := strings.TrimSpace(a.historyQuery)
267+ domain := strings.TrimSpace(a.historyDomainFilter)
209268 if query != "" {
210- return a.deps.History.Search(ctx, query, historyTimelineLimit)
269+ entries, err := a.deps.History.Search(ctx, query, historyTimelineLimit)
270+ if err != nil || domain == "" {
271+ return entries, err
272+ }
273+ return filterHistoryEntriesByDomain(entries, domain), nil
274+ }
275+ if domain != "" {
276+ return a.deps.History.TimelineByDomain(ctx, domain, historyTimelineLimit, a.historyOffset)
211277 }
212278 return a.deps.History.Timeline(ctx, historyTimelineLimit, a.historyOffset)
213279 }
@@ -3,6 +3,7 @@ package systemviews
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6+ "fmt"
6 "strings"7 "strings"
7 "sync"8 "sync"
8 9
@@ -44,6 +45,8 @@ type App struct {
44 configError string45 configError string
45 renderedHTML string46 renderedHTML string
46 actionQueue chan DOMAction47 actionQueue chan DOMAction
48+ actionCtx context.Context
49+ actionCancel context.CancelFunc
47 }50 }
48 51
49 const historyTimelineLimit = 2552 const historyTimelineLimit = 25
@@ -53,12 +56,19 @@ func NewApp(deps Dependencies) *App {
53 }56 }
54 57
55 func (a *App) Run() error {58 func (a *App) Run() error {
59+ return a.RunWithContext(context.Background())
60+}
61+
62+func (a *App) RunWithContext(ctx context.Context) error {
56 if a == nil {63 if a == nil {
57 return errors.New("app is nil")64 return errors.New("app is nil")
58 }65 }
66+ if ctx == nil {
67+ ctx = context.Background()
68+ }
59 69
60 a.currentRoute = ParseRoute(a.deps.LocationURI)70 a.currentRoute = ParseRoute(a.deps.LocationURI)
61- if err := a.LoadInitial(context.Background()); err != nil {71+ if err := a.LoadInitial(ctx); err != nil {
62 return err72 return err
63 }73 }
64 if a.deps.DOM == nil {74 if a.deps.DOM == nil {
@@ -69,21 +79,43 @@ func (a *App) Run() error {
69 return err79 return err
70 }80 }
71 if binder, ok := a.deps.DOM.(DOMActionBinder); ok {81 if binder, ok := a.deps.DOM.(DOMActionBinder); ok {
72- return a.bindDOMActions(context.Background(), binder)82+ return a.bindDOMActions(ctx, binder)
73 }83 }
74 return nil84 return nil
75 }85 }
76 86
77 func (a *App) bindDOMActions(ctx context.Context, binder DOMActionBinder) error {87 func (a *App) bindDOMActions(ctx context.Context, binder DOMActionBinder) error {
78 if a.actionQueue == nil {88 if a.actionQueue == nil {
89+ workerCtx, cancel := context.WithCancel(ctx)
79 a.actionQueue = make(chan DOMAction, 64)90 a.actionQueue = make(chan DOMAction, 64)
80- go a.runActionWorker(ctx)91+ a.actionCtx = workerCtx
92+ a.actionCancel = cancel
93+ go a.runActionWorker(workerCtx)
81 }94 }
82 return binder.BindActions(func(action DOMAction) {95 return binder.BindActions(func(action DOMAction) {
83- a.actionQueue <- action96+ if !a.enqueueDOMAction(action) {
97+ go a.surfaceActionError(fmt.Errorf("systemview is busy; dropped action %q", action.Action))
98+ }
84 })99 })
85 }100 }
86 101
102+func (a *App) enqueueDOMAction(action DOMAction) bool {
103+ if a == nil || a.actionQueue == nil {
104+ return false
105+ }
106+ if a.actionCtx == nil {
107+ return false
108+ }
109+ select {
110+ case <-a.actionCtx.Done():
111+ return false
112+ case a.actionQueue <- action:
113+ return true
114+ default:
115+ return false
116+ }
117+}
118+
87 func (a *App) runActionWorker(ctx context.Context) {119 func (a *App) runActionWorker(ctx context.Context) {
88 for {120 for {
89 select {121 select {
@@ -93,11 +125,38 @@ func (a *App) runActionWorker(ctx context.Context) {
93 if !ok {125 if !ok {
94 return126 return
95 }127 }
96- _ = a.HandleDOMAction(ctx, action)128+ if err := a.HandleDOMAction(ctx, action); err != nil {
129+ a.surfaceActionError(err)
130+ }
97 }131 }
98 }132 }
99 }133 }
100 134
135+func (a *App) surfaceActionError(err error) {
136+ if a == nil || err == nil {
137+ return
138+ }
139+ a.mu.Lock()
140+ a.renderRouteError(err)
141+ a.mu.Unlock()
142+ _ = a.mountRenderedHTML()
143+}
144+
145+func (a *App) Close() {
146+ if a == nil {
147+ return
148+ }
149+ if a.actionCancel != nil {
150+ a.actionCancel()
151+ a.actionCancel = nil
152+ }
153+ a.actionCtx = nil
154+ a.actionQueue = nil
155+ if releaser, ok := a.deps.DOM.(interface{ Release() }); ok {
156+ releaser.Release()
157+ }
158+}
159+
101 func (a *App) LoadInitial(ctx context.Context) error {160 func (a *App) LoadInitial(ctx context.Context) error {
102 if a == nil {161 if a == nil {
103 return errors.New("app is nil")162 return errors.New("app is nil")
@@ -182,7 +241,6 @@ func (a *App) loadHistoryRoute(ctx context.Context) error {
182 a.favorites = nil241 a.favorites = nil
183 a.folders = nil242 a.folders = nil
184 a.tags = nil243 a.tags = nil
185- entries = filterHistoryEntriesByDomain(entries, a.historyDomainFilter)
186 a.historyEntries = entries244 a.historyEntries = entries
187 a.historyAnalytics = analytics245 a.historyAnalytics = analytics
188 a.historyDomainStats = domains246 a.historyDomainStats = domains
@@ -206,8 +264,16 @@ func (a *App) loadHistoryRoute(ctx context.Context) error {
206 264
207 func (a *App) loadHistoryEntries(ctx context.Context) ([]*entity.HistoryEntry, error) {265 func (a *App) loadHistoryEntries(ctx context.Context) ([]*entity.HistoryEntry, error) {
208 query := strings.TrimSpace(a.historyQuery)266 query := strings.TrimSpace(a.historyQuery)
267+ domain := strings.TrimSpace(a.historyDomainFilter)
209 if query != "" {268 if query != "" {
210- return a.deps.History.Search(ctx, query, historyTimelineLimit)269+ entries, err := a.deps.History.Search(ctx, query, historyTimelineLimit)
270+ if err != nil || domain == "" {
271+ return entries, err
272+ }
273+ return filterHistoryEntriesByDomain(entries, domain), nil
274+ }
275+ if domain != "" {
276+ return a.deps.History.TimelineByDomain(ctx, domain, historyTimelineLimit, a.historyOffset)
211 }277 }
212 return a.deps.History.Timeline(ctx, historyTimelineLimit, a.historyOffset)278 return a.deps.History.Timeline(ctx, historyTimelineLimit, a.historyOffset)
213 }279 }
M internal/ui/systemviews/app_test.go +44 -6
@@ -110,7 +110,7 @@ func TestAppLoadInitialHistoryRouteRendersManagementActions(t *testing.T) {
110110 assert.Contains(t, app.renderedHTML, `data-range="hour"`)
111111 assert.Contains(t, app.renderedHTML, `data-sv-action="history.filterDomain"`)
112112 assert.Contains(t, app.renderedHTML, `data-sv-action="history.deleteDomain"`)
113- assert.Contains(t, app.renderedHTML, `data-domain="www.example.com:8080"`)
113+ assert.Contains(t, app.renderedHTML, `data-domain="example.com:8080"`)
114114 assert.Contains(t, app.renderedHTML, `>example.com</button>`)
115115 assert.Contains(t, app.renderedHTML, "Keys:")
116116 assert.Contains(t, app.renderedHTML, "Enter")
@@ -147,6 +147,14 @@ func TestAppHandleHistoryActionsRefreshesDOM(t *testing.T) {
147147 assert.Equal(t, "week", history.deletedRangeID)
148148 assert.Contains(t, dom.html, "Deleted history from this week")
149149
150+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{Action: historyActionClear}))
151+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
152+ Action: historyActionFilterDomain,
153+ Data: map[string]string{"domain": "example.com"},
154+ }))
155+ assert.True(t, history.domainCalled)
156+ assert.Equal(t, "example.com", history.domain)
157+
150158 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
151159 Action: historyActionDeleteDomain,
152160 Data: map[string]string{"domain": "example.com"},
@@ -320,6 +328,22 @@ func TestAppHandleFavoriteActionsRefreshesDOM(t *testing.T) {
320328 assert.Equal(t, int64(42), favorites.assignedFavoriteID)
321329 assert.Equal(t, int64(7), favorites.assignedTagID)
322330
331+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
332+ Action: favoriteActionFilterFolder,
333+ Data: map[string]string{"folderId": "5"},
334+ }))
335+ require.NotNil(t, app.favoriteFolderFilter)
336+ assert.Equal(t, entity.FolderID(5), *app.favoriteFolderFilter)
337+ assert.Nil(t, app.favoriteTagFilter)
338+
339+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
340+ Action: favoriteActionFilterTag,
341+ Data: map[string]string{"tagId": "7"},
342+ }))
343+ require.NotNil(t, app.favoriteTagFilter)
344+ assert.Equal(t, entity.TagID(7), *app.favoriteTagFilter)
345+ assert.Nil(t, app.favoriteFolderFilter)
346+
323347 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
324348 Action: favoriteActionDelete,
325349 Data: map[string]string{"id": "42"},
@@ -591,6 +615,7 @@ func testDefaultConfigPayload() port.SystemviewConfigPayload {
591615 return cfg
592616 }
593617
618+// Handwritten fake to capture DOM mounts for stateful render assertions.
594619 type fakeDOM struct {
595620 mounted bool
596621 html string
@@ -602,12 +627,15 @@ func (d *fakeDOM) Mount(html string) error {
602627 return nil
603628 }
604629
630+// Handwritten fake to capture history state for stateful render assertions.
605631 type fakeHistoryService struct {
606- called bool
607- limit int
608- offset int
609- entries []*entity.HistoryEntry
610- err error
632+ called bool
633+ limit int
634+ offset int
635+ entries []*entity.HistoryEntry
636+ err error
637+ domainCalled bool
638+ domain string
611639
612640 searchCalled bool
613641 query string
@@ -627,6 +655,14 @@ func (s *fakeHistoryService) Timeline(_ context.Context, limit, offset int) ([]*
627655 return s.entries, s.err
628656 }
629657
658+func (s *fakeHistoryService) TimelineByDomain(_ context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) {
659+ s.domainCalled = true
660+ s.domain = domain
661+ s.limit = limit
662+ s.offset = offset
663+ return s.entries, s.err
664+}
665+
630666 func (s *fakeHistoryService) Search(_ context.Context, query string, limit int) ([]*entity.HistoryEntry, error) {
631667 s.searchCalled = true
632668 s.query = query
@@ -660,6 +696,7 @@ func (s *fakeHistoryService) DeleteDomain(_ context.Context, domain string) erro
660696 return nil
661697 }
662698
699+// Handwritten fake to capture favorites state for stateful render assertions.
663700 type fakeFavoritesService struct {
664701 calledList bool
665702 calledFolders bool
@@ -759,6 +796,7 @@ func (s *fakeFavoritesService) RemoveTag(_ context.Context, favoriteID, tagID in
759796 return nil
760797 }
761798
799+// Handwritten fake to capture config state for stateful render assertions.
762800 type fakeConfigService struct {
763801 calledCurrent bool
764802 calledDefault bool
@@ -110,7 +110,7 @@ func TestAppLoadInitialHistoryRouteRendersManagementActions(t *testing.T) {
110 assert.Contains(t, app.renderedHTML, `data-range="hour"`)110 assert.Contains(t, app.renderedHTML, `data-range="hour"`)
111 assert.Contains(t, app.renderedHTML, `data-sv-action="history.filterDomain"`)111 assert.Contains(t, app.renderedHTML, `data-sv-action="history.filterDomain"`)
112 assert.Contains(t, app.renderedHTML, `data-sv-action="history.deleteDomain"`)112 assert.Contains(t, app.renderedHTML, `data-sv-action="history.deleteDomain"`)
113- assert.Contains(t, app.renderedHTML, `data-domain="www.example.com:8080"`)113+ assert.Contains(t, app.renderedHTML, `data-domain="example.com:8080"`)
114 assert.Contains(t, app.renderedHTML, `>example.com</button>`)114 assert.Contains(t, app.renderedHTML, `>example.com</button>`)
115 assert.Contains(t, app.renderedHTML, "Keys:")115 assert.Contains(t, app.renderedHTML, "Keys:")
116 assert.Contains(t, app.renderedHTML, "Enter")116 assert.Contains(t, app.renderedHTML, "Enter")
@@ -147,6 +147,14 @@ func TestAppHandleHistoryActionsRefreshesDOM(t *testing.T) {
147 assert.Equal(t, "week", history.deletedRangeID)147 assert.Equal(t, "week", history.deletedRangeID)
148 assert.Contains(t, dom.html, "Deleted history from this week")148 assert.Contains(t, dom.html, "Deleted history from this week")
149 149
150+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{Action: historyActionClear}))
151+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
152+ Action: historyActionFilterDomain,
153+ Data: map[string]string{"domain": "example.com"},
154+ }))
155+ assert.True(t, history.domainCalled)
156+ assert.Equal(t, "example.com", history.domain)
157+
150 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{158 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
151 Action: historyActionDeleteDomain,159 Action: historyActionDeleteDomain,
152 Data: map[string]string{"domain": "example.com"},160 Data: map[string]string{"domain": "example.com"},
@@ -320,6 +328,22 @@ func TestAppHandleFavoriteActionsRefreshesDOM(t *testing.T) {
320 assert.Equal(t, int64(42), favorites.assignedFavoriteID)328 assert.Equal(t, int64(42), favorites.assignedFavoriteID)
321 assert.Equal(t, int64(7), favorites.assignedTagID)329 assert.Equal(t, int64(7), favorites.assignedTagID)
322 330
331+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
332+ Action: favoriteActionFilterFolder,
333+ Data: map[string]string{"folderId": "5"},
334+ }))
335+ require.NotNil(t, app.favoriteFolderFilter)
336+ assert.Equal(t, entity.FolderID(5), *app.favoriteFolderFilter)
337+ assert.Nil(t, app.favoriteTagFilter)
338+
339+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
340+ Action: favoriteActionFilterTag,
341+ Data: map[string]string{"tagId": "7"},
342+ }))
343+ require.NotNil(t, app.favoriteTagFilter)
344+ assert.Equal(t, entity.TagID(7), *app.favoriteTagFilter)
345+ assert.Nil(t, app.favoriteFolderFilter)
346+
323 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{347 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
324 Action: favoriteActionDelete,348 Action: favoriteActionDelete,
325 Data: map[string]string{"id": "42"},349 Data: map[string]string{"id": "42"},
@@ -591,6 +615,7 @@ func testDefaultConfigPayload() port.SystemviewConfigPayload {
591 return cfg615 return cfg
592 }616 }
593 617
618+// Handwritten fake to capture DOM mounts for stateful render assertions.
594 type fakeDOM struct {619 type fakeDOM struct {
595 mounted bool620 mounted bool
596 html string621 html string
@@ -602,12 +627,15 @@ func (d *fakeDOM) Mount(html string) error {
602 return nil627 return nil
603 }628 }
604 629
630+// Handwritten fake to capture history state for stateful render assertions.
605 type fakeHistoryService struct {631 type fakeHistoryService struct {
606- called bool632+ called bool
607- limit int633+ limit int
608- offset int634+ offset int
609- entries []*entity.HistoryEntry635+ entries []*entity.HistoryEntry
610- err error636+ err error
637+ domainCalled bool
638+ domain string
611 639
612 searchCalled bool640 searchCalled bool
613 query string641 query string
@@ -627,6 +655,14 @@ func (s *fakeHistoryService) Timeline(_ context.Context, limit, offset int) ([]*
627 return s.entries, s.err655 return s.entries, s.err
628 }656 }
629 657
658+func (s *fakeHistoryService) TimelineByDomain(_ context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) {
659+ s.domainCalled = true
660+ s.domain = domain
661+ s.limit = limit
662+ s.offset = offset
663+ return s.entries, s.err
664+}
665+
630 func (s *fakeHistoryService) Search(_ context.Context, query string, limit int) ([]*entity.HistoryEntry, error) {666 func (s *fakeHistoryService) Search(_ context.Context, query string, limit int) ([]*entity.HistoryEntry, error) {
631 s.searchCalled = true667 s.searchCalled = true
632 s.query = query668 s.query = query
@@ -660,6 +696,7 @@ func (s *fakeHistoryService) DeleteDomain(_ context.Context, domain string) erro
660 return nil696 return nil
661 }697 }
662 698
699+// Handwritten fake to capture favorites state for stateful render assertions.
663 type fakeFavoritesService struct {700 type fakeFavoritesService struct {
664 calledList bool701 calledList bool
665 calledFolders bool702 calledFolders bool
@@ -759,6 +796,7 @@ func (s *fakeFavoritesService) RemoveTag(_ context.Context, favoriteID, tagID in
759 return nil796 return nil
760 }797 }
761 798
799+// Handwritten fake to capture config state for stateful render assertions.
762 type fakeConfigService struct {800 type fakeConfigService struct {
763 calledCurrent bool801 calledCurrent bool
764 calledDefault bool802 calledDefault bool
M internal/ui/systemviews/config.templ +6 -5
@@ -71,11 +71,12 @@ templ SearchSettings(config port.SystemviewConfigPayload) {
7171 <label><span>Description</span><input name="description" type="text" placeholder="DuckDuckGo"/></label>
7272 <button class="sv-button sv-button-secondary" type="submit">Add shortcut</button>
7373 </form>
74- if len(searchShortcutRows(config.SearchShortcuts)) == 0 {
74+ {{ shortcuts := searchShortcutRows(config.SearchShortcuts) }}
75+ if len(shortcuts) == 0 {
7576 @EmptyState("No search shortcuts configured")
7677 } else {
7778 <ul class="sv-list">
78- for _, shortcut := range searchShortcutRows(config.SearchShortcuts) {
79+ for _, shortcut := range shortcuts {
7980 @SearchShortcutItem(shortcut)
8081 }
8182 </ul>
@@ -126,14 +127,14 @@ templ PerformanceSettings(config port.SystemviewConfigPayload) {
126127
127128 templ ConfigKeybindings(keybindings port.KeybindingsConfig) {
128129 {{ groups := configKeybindingGroups(keybindings) }}
129- <div class="sv-button-row">
130- <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?">Reset all keybindings</button>
131- </div>
132130 if groups == nil {
133131 @EmptyState("Keybindings unavailable")
134132 } else if len(groups) == 0 {
135133 @EmptyState("No keybindings configured")
136134 } else {
135+ <div class="sv-button-row">
136+ <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?">Reset all keybindings</button>
137+ </div>
137138 for _, group := range groups {
138139 @ConfigKeybindingGroup(group)
139140 }
@@ -71,11 +71,12 @@ templ SearchSettings(config port.SystemviewConfigPayload) {
71 <label><span>Description</span><input name="description" type="text" placeholder="DuckDuckGo"/></label>71 <label><span>Description</span><input name="description" type="text" placeholder="DuckDuckGo"/></label>
72 <button class="sv-button sv-button-secondary" type="submit">Add shortcut</button>72 <button class="sv-button sv-button-secondary" type="submit">Add shortcut</button>
73 </form>73 </form>
74- if len(searchShortcutRows(config.SearchShortcuts)) == 0 {74+ {{ shortcuts := searchShortcutRows(config.SearchShortcuts) }}
75+ if len(shortcuts) == 0 {
75 @EmptyState("No search shortcuts configured")76 @EmptyState("No search shortcuts configured")
76 } else {77 } else {
77 <ul class="sv-list">78 <ul class="sv-list">
78- for _, shortcut := range searchShortcutRows(config.SearchShortcuts) {79+ for _, shortcut := range shortcuts {
79 @SearchShortcutItem(shortcut)80 @SearchShortcutItem(shortcut)
80 }81 }
81 </ul>82 </ul>
@@ -126,14 +127,14 @@ templ PerformanceSettings(config port.SystemviewConfigPayload) {
126 127
127 templ ConfigKeybindings(keybindings port.KeybindingsConfig) {128 templ ConfigKeybindings(keybindings port.KeybindingsConfig) {
128 {{ groups := configKeybindingGroups(keybindings) }}129 {{ groups := configKeybindingGroups(keybindings) }}
129- <div class="sv-button-row">
130- <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?">Reset all keybindings</button>
131- </div>
132 if groups == nil {130 if groups == nil {
133 @EmptyState("Keybindings unavailable")131 @EmptyState("Keybindings unavailable")
134 } else if len(groups) == 0 {132 } else if len(groups) == 0 {
135 @EmptyState("No keybindings configured")133 @EmptyState("No keybindings configured")
136 } else {134 } else {
135+ <div class="sv-button-row">
136+ <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?">Reset all keybindings</button>
137+ </div>
137 for _, group := range groups {138 for _, group := range groups {
138 @ConfigKeybindingGroup(group)139 @ConfigKeybindingGroup(group)
139 }140 }
M internal/ui/systemviews/config_actions.go +6 -0
@@ -124,6 +124,9 @@ func (a *App) createSearchShortcut(ctx context.Context, data map[string]string)
124124 return err
125125 }
126126 key, shortcut := searchShortcutFromForm(data, "key")
127+ if key == "" {
128+ return fmt.Errorf("search shortcut key is required")
129+ }
127130 cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts)
128131 if _, exists := cfg.SearchShortcuts[key]; exists {
129132 return fmt.Errorf("search shortcut %q already exists", key)
@@ -146,6 +149,9 @@ func (a *App) updateSearchShortcut(ctx context.Context, data map[string]string)
146149 return fmt.Errorf("search shortcut key is required")
147150 }
148151 newKey, shortcut := searchShortcutFromForm(data, "new_key")
152+ if newKey == "" {
153+ return fmt.Errorf("new search shortcut key is required")
154+ }
149155 cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts)
150156 if _, exists := cfg.SearchShortcuts[oldKey]; !exists {
151157 return fmt.Errorf("search shortcut %q not found", oldKey)
@@ -124,6 +124,9 @@ func (a *App) createSearchShortcut(ctx context.Context, data map[string]string)
124 return err124 return err
125 }125 }
126 key, shortcut := searchShortcutFromForm(data, "key")126 key, shortcut := searchShortcutFromForm(data, "key")
127+ if key == "" {
128+ return fmt.Errorf("search shortcut key is required")
129+ }
127 cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts)130 cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts)
128 if _, exists := cfg.SearchShortcuts[key]; exists {131 if _, exists := cfg.SearchShortcuts[key]; exists {
129 return fmt.Errorf("search shortcut %q already exists", key)132 return fmt.Errorf("search shortcut %q already exists", key)
@@ -146,6 +149,9 @@ func (a *App) updateSearchShortcut(ctx context.Context, data map[string]string)
146 return fmt.Errorf("search shortcut key is required")149 return fmt.Errorf("search shortcut key is required")
147 }150 }
148 newKey, shortcut := searchShortcutFromForm(data, "new_key")151 newKey, shortcut := searchShortcutFromForm(data, "new_key")
152+ if newKey == "" {
153+ return fmt.Errorf("new search shortcut key is required")
154+ }
149 cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts)155 cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts)
150 if _, exists := cfg.SearchShortcuts[oldKey]; !exists {156 if _, exists := cfg.SearchShortcuts[oldKey]; !exists {
151 return fmt.Errorf("search shortcut %q not found", oldKey)157 return fmt.Errorf("search shortcut %q not found", oldKey)
M internal/ui/systemviews/config_templ.go +31 -30
@@ -558,7 +558,8 @@ func SearchSettings(config port.SystemviewConfigPayload) templ.Component {
558558 if templ_7745c5c3_Err != nil {
559559 return templ_7745c5c3_Err
560560 }
561- if len(searchShortcutRows(config.SearchShortcuts)) == 0 {
561+ shortcuts := searchShortcutRows(config.SearchShortcuts)
562+ if len(shortcuts) == 0 {
562563 templ_7745c5c3_Err = EmptyState("No search shortcuts configured").Render(ctx, templ_7745c5c3_Buffer)
563564 if templ_7745c5c3_Err != nil {
564565 return templ_7745c5c3_Err
@@ -568,7 +569,7 @@ func SearchSettings(config port.SystemviewConfigPayload) templ.Component {
568569 if templ_7745c5c3_Err != nil {
569570 return templ_7745c5c3_Err
570571 }
571- for _, shortcut := range searchShortcutRows(config.SearchShortcuts) {
572+ for _, shortcut := range shortcuts {
572573 templ_7745c5c3_Err = SearchShortcutItem(shortcut).Render(ctx, templ_7745c5c3_Buffer)
573574 if templ_7745c5c3_Err != nil {
574575 return templ_7745c5c3_Err
@@ -615,7 +616,7 @@ func SearchShortcutItem(shortcut searchShortcutRow) templ.Component {
615616 var templ_7745c5c3_Var32 string
616617 templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(shortcut.Key)
617618 if templ_7745c5c3_Err != nil {
618- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 89, Col: 55}
619+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 90, Col: 55}
619620 }
620621 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
621622 if templ_7745c5c3_Err != nil {
@@ -628,7 +629,7 @@ func SearchShortcutItem(shortcut searchShortcutRow) templ.Component {
628629 var templ_7745c5c3_Var33 string
629630 templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(shortcut.Key)
630631 if templ_7745c5c3_Err != nil {
631- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 90, Col: 80}
632+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 91, Col: 80}
632633 }
633634 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
634635 if templ_7745c5c3_Err != nil {
@@ -641,7 +642,7 @@ func SearchShortcutItem(shortcut searchShortcutRow) templ.Component {
641642 var templ_7745c5c3_Var34 string
642643 templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(shortcut.URL)
643644 if templ_7745c5c3_Err != nil {
644- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 91, Col: 76}
645+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 92, Col: 76}
645646 }
646647 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
647648 if templ_7745c5c3_Err != nil {
@@ -654,7 +655,7 @@ func SearchShortcutItem(shortcut searchShortcutRow) templ.Component {
654655 var templ_7745c5c3_Var35 string
655656 templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(shortcut.Description)
656657 if templ_7745c5c3_Err != nil {
657- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 92, Col: 100}
658+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 93, Col: 100}
658659 }
659660 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
660661 if templ_7745c5c3_Err != nil {
@@ -667,7 +668,7 @@ func SearchShortcutItem(shortcut searchShortcutRow) templ.Component {
667668 var templ_7745c5c3_Var36 string
668669 templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(shortcut.Key)
669670 if templ_7745c5c3_Err != nil {
670- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 94, Col: 149}
671+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 95, Col: 149}
671672 }
672673 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
673674 if templ_7745c5c3_Err != nil {
@@ -749,7 +750,7 @@ func PerformanceSettings(config port.SystemviewConfigPayload) templ.Component {
749750 var templ_7745c5c3_Var38 string
750751 templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Custom.SkiaCPUThreads))
751752 if templ_7745c5c3_Err != nil {
752- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 107, Col: 164}
753+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 108, Col: 164}
753754 }
754755 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
755756 if templ_7745c5c3_Err != nil {
@@ -762,7 +763,7 @@ func PerformanceSettings(config port.SystemviewConfigPayload) templ.Component {
762763 var templ_7745c5c3_Var39 string
763764 templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Custom.SkiaGPUThreads))
764765 if templ_7745c5c3_Err != nil {
765- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 108, Col: 165}
766+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 109, Col: 165}
766767 }
767768 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
768769 if templ_7745c5c3_Err != nil {
@@ -775,7 +776,7 @@ func PerformanceSettings(config port.SystemviewConfigPayload) templ.Component {
775776 var templ_7745c5c3_Var40 string
776777 templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Custom.WebProcessMemoryMB))
777778 if templ_7745c5c3_Err != nil {
778- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 109, Col: 182}
779+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 110, Col: 182}
779780 }
780781 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
781782 if templ_7745c5c3_Err != nil {
@@ -788,7 +789,7 @@ func PerformanceSettings(config port.SystemviewConfigPayload) templ.Component {
788789 var templ_7745c5c3_Var41 string
789790 templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Custom.NetworkProcessMemoryMB))
790791 if templ_7745c5c3_Err != nil {
791- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 110, Col: 193}
792+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 111, Col: 193}
792793 }
793794 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
794795 if templ_7745c5c3_Err != nil {
@@ -801,7 +802,7 @@ func PerformanceSettings(config port.SystemviewConfigPayload) templ.Component {
801802 var templ_7745c5c3_Var42 string
802803 templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Custom.WebViewPoolPrewarm))
803804 if templ_7745c5c3_Err != nil {
804- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 111, Col: 177}
805+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 112, Col: 177}
805806 }
806807 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
807808 if templ_7745c5c3_Err != nil {
@@ -814,7 +815,7 @@ func PerformanceSettings(config port.SystemviewConfigPayload) templ.Component {
814815 var templ_7745c5c3_Var43 string
815816 templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Hardware.CPUThreads))
816817 if templ_7745c5c3_Err != nil {
817- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 115, Col: 113}
818+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 116, Col: 113}
818819 }
819820 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
820821 if templ_7745c5c3_Err != nil {
@@ -827,7 +828,7 @@ func PerformanceSettings(config port.SystemviewConfigPayload) templ.Component {
827828 var templ_7745c5c3_Var44 string
828829 templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Hardware.TotalRAMMB))
829830 if templ_7745c5c3_Err != nil {
830- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 116, Col: 113}
831+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 117, Col: 113}
831832 }
832833 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
833834 if templ_7745c5c3_Err != nil {
@@ -840,7 +841,7 @@ func PerformanceSettings(config port.SystemviewConfigPayload) templ.Component {
840841 var templ_7745c5c3_Var45 string
841842 templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(config.Performance.Hardware.GPUName)
842843 if templ_7745c5c3_Err != nil {
843- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 117, Col: 93}
844+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 118, Col: 93}
844845 }
845846 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
846847 if templ_7745c5c3_Err != nil {
@@ -853,7 +854,7 @@ func PerformanceSettings(config port.SystemviewConfigPayload) templ.Component {
853854 var templ_7745c5c3_Var46 string
854855 templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(config.EngineType)
855856 if templ_7745c5c3_Err != nil {
856- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 118, Col: 75}
857+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 119, Col: 75}
857858 }
858859 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
859860 if templ_7745c5c3_Err != nil {
@@ -900,10 +901,6 @@ func ConfigKeybindings(keybindings port.KeybindingsConfig) templ.Component {
900901 }
901902 ctx = templ.ClearChildren(ctx)
902903 groups := configKeybindingGroups(keybindings)
903- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<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?\">Reset all keybindings</button></div>")
904- if templ_7745c5c3_Err != nil {
905- return templ_7745c5c3_Err
906- }
907904 if groups == nil {
908905 templ_7745c5c3_Err = EmptyState("Keybindings unavailable").Render(ctx, templ_7745c5c3_Buffer)
909906 if templ_7745c5c3_Err != nil {
@@ -915,6 +912,10 @@ func ConfigKeybindings(keybindings port.KeybindingsConfig) templ.Component {
915912 return templ_7745c5c3_Err
916913 }
917914 } else {
915+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<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?\">Reset all keybindings</button></div>")
916+ if templ_7745c5c3_Err != nil {
917+ return templ_7745c5c3_Err
918+ }
918919 for _, group := range groups {
919920 templ_7745c5c3_Err = ConfigKeybindingGroup(group).Render(ctx, templ_7745c5c3_Buffer)
920921 if templ_7745c5c3_Err != nil {
@@ -954,7 +955,7 @@ func ConfigKeybindingGroup(group renderedKeybindingGroup) templ.Component {
954955 var templ_7745c5c3_Var49 string
955956 templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingGroupTitle(group))
956957 if templ_7745c5c3_Err != nil {
957- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 147, Col: 37}
958+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 148, Col: 37}
958959 }
959960 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
960961 if templ_7745c5c3_Err != nil {
@@ -972,7 +973,7 @@ func ConfigKeybindingGroup(group renderedKeybindingGroup) templ.Component {
972973 var templ_7745c5c3_Var50 string
973974 templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingGroupMeta(group))
974975 if templ_7745c5c3_Err != nil {
975- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 149, Col: 52}
976+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 150, Col: 52}
976977 }
977978 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
978979 if templ_7745c5c3_Err != nil {
@@ -1044,7 +1045,7 @@ func ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding)
10441045 var templ_7745c5c3_Var52 string
10451046 templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingDescription(binding))
10461047 if templ_7745c5c3_Err != nil {
1047- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 171, Col: 44}
1048+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 172, Col: 44}
10481049 }
10491050 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
10501051 if templ_7745c5c3_Err != nil {
@@ -1057,7 +1058,7 @@ func ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding)
10571058 var templ_7745c5c3_Var53 string
10581059 templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingAction(binding))
10591060 if templ_7745c5c3_Err != nil {
1060- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 172, Col: 50}
1061+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 173, Col: 50}
10611062 }
10621063 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
10631064 if templ_7745c5c3_Err != nil {
@@ -1078,7 +1079,7 @@ func ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding)
10781079 var templ_7745c5c3_Var54 string
10791080 templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingStatus(binding))
10801081 if templ_7745c5c3_Err != nil {
1081- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 175, Col: 55}
1082+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 176, Col: 55}
10821083 }
10831084 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
10841085 if templ_7745c5c3_Err != nil {
@@ -1105,7 +1106,7 @@ func ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding)
11051106 var templ_7745c5c3_Var55 string
11061107 templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(group.Mode)
11071108 if templ_7745c5c3_Err != nil {
1108- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 183, Col: 55}
1109+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 184, Col: 55}
11091110 }
11101111 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
11111112 if templ_7745c5c3_Err != nil {
@@ -1118,7 +1119,7 @@ func ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding)
11181119 var templ_7745c5c3_Var56 string
11191120 templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(binding.Action)
11201121 if templ_7745c5c3_Err != nil {
1121- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 184, Col: 61}
1122+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 185, Col: 61}
11221123 }
11231124 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
11241125 if templ_7745c5c3_Err != nil {
@@ -1131,7 +1132,7 @@ func ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding)
11311132 var templ_7745c5c3_Var57 string
11321133 templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingKeysValue(binding.Keys))
11331134 if templ_7745c5c3_Err != nil {
1134- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 185, Col: 100}
1135+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 186, Col: 100}
11351136 }
11361137 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))
11371138 if templ_7745c5c3_Err != nil {
@@ -1144,7 +1145,7 @@ func ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding)
11441145 var templ_7745c5c3_Var58 string
11451146 templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(group.Mode)
11461147 if templ_7745c5c3_Err != nil {
1147- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 187, Col: 127}
1148+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 188, Col: 127}
11481149 }
11491150 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58))
11501151 if templ_7745c5c3_Err != nil {
@@ -1157,7 +1158,7 @@ func ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding)
11571158 var templ_7745c5c3_Var59 string
11581159 templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(binding.Action)
11591160 if templ_7745c5c3_Err != nil {
1160- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 187, Col: 158}
1161+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 188, Col: 158}
11611162 }
11621163 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59))
11631164 if templ_7745c5c3_Err != nil {
@@ -558,7 +558,8 @@ func SearchSettings(config port.SystemviewConfigPayload) templ.Component {
558 if templ_7745c5c3_Err != nil {558 if templ_7745c5c3_Err != nil {
559 return templ_7745c5c3_Err559 return templ_7745c5c3_Err
560 }560 }
561- if len(searchShortcutRows(config.SearchShortcuts)) == 0 {561+ shortcuts := searchShortcutRows(config.SearchShortcuts)
562+ if len(shortcuts) == 0 {
562 templ_7745c5c3_Err = EmptyState("No search shortcuts configured").Render(ctx, templ_7745c5c3_Buffer)563 templ_7745c5c3_Err = EmptyState("No search shortcuts configured").Render(ctx, templ_7745c5c3_Buffer)
563 if templ_7745c5c3_Err != nil {564 if templ_7745c5c3_Err != nil {
564 return templ_7745c5c3_Err565 return templ_7745c5c3_Err
@@ -568,7 +569,7 @@ func SearchSettings(config port.SystemviewConfigPayload) templ.Component {
568 if templ_7745c5c3_Err != nil {569 if templ_7745c5c3_Err != nil {
569 return templ_7745c5c3_Err570 return templ_7745c5c3_Err
570 }571 }
571- for _, shortcut := range searchShortcutRows(config.SearchShortcuts) {572+ for _, shortcut := range shortcuts {
572 templ_7745c5c3_Err = SearchShortcutItem(shortcut).Render(ctx, templ_7745c5c3_Buffer)573 templ_7745c5c3_Err = SearchShortcutItem(shortcut).Render(ctx, templ_7745c5c3_Buffer)
573 if templ_7745c5c3_Err != nil {574 if templ_7745c5c3_Err != nil {
574 return templ_7745c5c3_Err575 return templ_7745c5c3_Err
@@ -615,7 +616,7 @@ func SearchShortcutItem(shortcut searchShortcutRow) templ.Component {
615 var templ_7745c5c3_Var32 string616 var templ_7745c5c3_Var32 string
616 templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(shortcut.Key)617 templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(shortcut.Key)
617 if templ_7745c5c3_Err != nil {618 if templ_7745c5c3_Err != nil {
618- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 89, Col: 55}619+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 90, Col: 55}
619 }620 }
620 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))621 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
621 if templ_7745c5c3_Err != nil {622 if templ_7745c5c3_Err != nil {
@@ -628,7 +629,7 @@ func SearchShortcutItem(shortcut searchShortcutRow) templ.Component {
628 var templ_7745c5c3_Var33 string629 var templ_7745c5c3_Var33 string
629 templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(shortcut.Key)630 templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(shortcut.Key)
630 if templ_7745c5c3_Err != nil {631 if templ_7745c5c3_Err != nil {
631- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 90, Col: 80}632+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 91, Col: 80}
632 }633 }
633 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))634 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
634 if templ_7745c5c3_Err != nil {635 if templ_7745c5c3_Err != nil {
@@ -641,7 +642,7 @@ func SearchShortcutItem(shortcut searchShortcutRow) templ.Component {
641 var templ_7745c5c3_Var34 string642 var templ_7745c5c3_Var34 string
642 templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(shortcut.URL)643 templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(shortcut.URL)
643 if templ_7745c5c3_Err != nil {644 if templ_7745c5c3_Err != nil {
644- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 91, Col: 76}645+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 92, Col: 76}
645 }646 }
646 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))647 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
647 if templ_7745c5c3_Err != nil {648 if templ_7745c5c3_Err != nil {
@@ -654,7 +655,7 @@ func SearchShortcutItem(shortcut searchShortcutRow) templ.Component {
654 var templ_7745c5c3_Var35 string655 var templ_7745c5c3_Var35 string
655 templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(shortcut.Description)656 templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(shortcut.Description)
656 if templ_7745c5c3_Err != nil {657 if templ_7745c5c3_Err != nil {
657- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 92, Col: 100}658+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 93, Col: 100}
658 }659 }
659 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))660 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
660 if templ_7745c5c3_Err != nil {661 if templ_7745c5c3_Err != nil {
@@ -667,7 +668,7 @@ func SearchShortcutItem(shortcut searchShortcutRow) templ.Component {
667 var templ_7745c5c3_Var36 string668 var templ_7745c5c3_Var36 string
668 templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(shortcut.Key)669 templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(shortcut.Key)
669 if templ_7745c5c3_Err != nil {670 if templ_7745c5c3_Err != nil {
670- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 94, Col: 149}671+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 95, Col: 149}
671 }672 }
672 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))673 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
673 if templ_7745c5c3_Err != nil {674 if templ_7745c5c3_Err != nil {
@@ -749,7 +750,7 @@ func PerformanceSettings(config port.SystemviewConfigPayload) templ.Component {
749 var templ_7745c5c3_Var38 string750 var templ_7745c5c3_Var38 string
750 templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Custom.SkiaCPUThreads))751 templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Custom.SkiaCPUThreads))
751 if templ_7745c5c3_Err != nil {752 if templ_7745c5c3_Err != nil {
752- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 107, Col: 164}753+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 108, Col: 164}
753 }754 }
754 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))755 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
755 if templ_7745c5c3_Err != nil {756 if templ_7745c5c3_Err != nil {
@@ -762,7 +763,7 @@ func PerformanceSettings(config port.SystemviewConfigPayload) templ.Component {
762 var templ_7745c5c3_Var39 string763 var templ_7745c5c3_Var39 string
763 templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Custom.SkiaGPUThreads))764 templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Custom.SkiaGPUThreads))
764 if templ_7745c5c3_Err != nil {765 if templ_7745c5c3_Err != nil {
765- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 108, Col: 165}766+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 109, Col: 165}
766 }767 }
767 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))768 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
768 if templ_7745c5c3_Err != nil {769 if templ_7745c5c3_Err != nil {
@@ -775,7 +776,7 @@ func PerformanceSettings(config port.SystemviewConfigPayload) templ.Component {
775 var templ_7745c5c3_Var40 string776 var templ_7745c5c3_Var40 string
776 templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Custom.WebProcessMemoryMB))777 templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Custom.WebProcessMemoryMB))
777 if templ_7745c5c3_Err != nil {778 if templ_7745c5c3_Err != nil {
778- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 109, Col: 182}779+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 110, Col: 182}
779 }780 }
780 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))781 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
781 if templ_7745c5c3_Err != nil {782 if templ_7745c5c3_Err != nil {
@@ -788,7 +789,7 @@ func PerformanceSettings(config port.SystemviewConfigPayload) templ.Component {
788 var templ_7745c5c3_Var41 string789 var templ_7745c5c3_Var41 string
789 templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Custom.NetworkProcessMemoryMB))790 templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Custom.NetworkProcessMemoryMB))
790 if templ_7745c5c3_Err != nil {791 if templ_7745c5c3_Err != nil {
791- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 110, Col: 193}792+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 111, Col: 193}
792 }793 }
793 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))794 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
794 if templ_7745c5c3_Err != nil {795 if templ_7745c5c3_Err != nil {
@@ -801,7 +802,7 @@ func PerformanceSettings(config port.SystemviewConfigPayload) templ.Component {
801 var templ_7745c5c3_Var42 string802 var templ_7745c5c3_Var42 string
802 templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Custom.WebViewPoolPrewarm))803 templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Custom.WebViewPoolPrewarm))
803 if templ_7745c5c3_Err != nil {804 if templ_7745c5c3_Err != nil {
804- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 111, Col: 177}805+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 112, Col: 177}
805 }806 }
806 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))807 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
807 if templ_7745c5c3_Err != nil {808 if templ_7745c5c3_Err != nil {
@@ -814,7 +815,7 @@ func PerformanceSettings(config port.SystemviewConfigPayload) templ.Component {
814 var templ_7745c5c3_Var43 string815 var templ_7745c5c3_Var43 string
815 templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Hardware.CPUThreads))816 templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Hardware.CPUThreads))
816 if templ_7745c5c3_Err != nil {817 if templ_7745c5c3_Err != nil {
817- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 115, Col: 113}818+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 116, Col: 113}
818 }819 }
819 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))820 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
820 if templ_7745c5c3_Err != nil {821 if templ_7745c5c3_Err != nil {
@@ -827,7 +828,7 @@ func PerformanceSettings(config port.SystemviewConfigPayload) templ.Component {
827 var templ_7745c5c3_Var44 string828 var templ_7745c5c3_Var44 string
828 templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Hardware.TotalRAMMB))829 templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Hardware.TotalRAMMB))
829 if templ_7745c5c3_Err != nil {830 if templ_7745c5c3_Err != nil {
830- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 116, Col: 113}831+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 117, Col: 113}
831 }832 }
832 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))833 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
833 if templ_7745c5c3_Err != nil {834 if templ_7745c5c3_Err != nil {
@@ -840,7 +841,7 @@ func PerformanceSettings(config port.SystemviewConfigPayload) templ.Component {
840 var templ_7745c5c3_Var45 string841 var templ_7745c5c3_Var45 string
841 templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(config.Performance.Hardware.GPUName)842 templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(config.Performance.Hardware.GPUName)
842 if templ_7745c5c3_Err != nil {843 if templ_7745c5c3_Err != nil {
843- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 117, Col: 93}844+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 118, Col: 93}
844 }845 }
845 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))846 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
846 if templ_7745c5c3_Err != nil {847 if templ_7745c5c3_Err != nil {
@@ -853,7 +854,7 @@ func PerformanceSettings(config port.SystemviewConfigPayload) templ.Component {
853 var templ_7745c5c3_Var46 string854 var templ_7745c5c3_Var46 string
854 templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(config.EngineType)855 templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(config.EngineType)
855 if templ_7745c5c3_Err != nil {856 if templ_7745c5c3_Err != nil {
856- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 118, Col: 75}857+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 119, Col: 75}
857 }858 }
858 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))859 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
859 if templ_7745c5c3_Err != nil {860 if templ_7745c5c3_Err != nil {
@@ -900,10 +901,6 @@ func ConfigKeybindings(keybindings port.KeybindingsConfig) templ.Component {
900 }901 }
901 ctx = templ.ClearChildren(ctx)902 ctx = templ.ClearChildren(ctx)
902 groups := configKeybindingGroups(keybindings)903 groups := configKeybindingGroups(keybindings)
903- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<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?\">Reset all keybindings</button></div>")
904- if templ_7745c5c3_Err != nil {
905- return templ_7745c5c3_Err
906- }
907 if groups == nil {904 if groups == nil {
908 templ_7745c5c3_Err = EmptyState("Keybindings unavailable").Render(ctx, templ_7745c5c3_Buffer)905 templ_7745c5c3_Err = EmptyState("Keybindings unavailable").Render(ctx, templ_7745c5c3_Buffer)
909 if templ_7745c5c3_Err != nil {906 if templ_7745c5c3_Err != nil {
@@ -915,6 +912,10 @@ func ConfigKeybindings(keybindings port.KeybindingsConfig) templ.Component {
915 return templ_7745c5c3_Err912 return templ_7745c5c3_Err
916 }913 }
917 } else {914 } else {
915+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<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?\">Reset all keybindings</button></div>")
916+ if templ_7745c5c3_Err != nil {
917+ return templ_7745c5c3_Err
918+ }
918 for _, group := range groups {919 for _, group := range groups {
919 templ_7745c5c3_Err = ConfigKeybindingGroup(group).Render(ctx, templ_7745c5c3_Buffer)920 templ_7745c5c3_Err = ConfigKeybindingGroup(group).Render(ctx, templ_7745c5c3_Buffer)
920 if templ_7745c5c3_Err != nil {921 if templ_7745c5c3_Err != nil {
@@ -954,7 +955,7 @@ func ConfigKeybindingGroup(group renderedKeybindingGroup) templ.Component {
954 var templ_7745c5c3_Var49 string955 var templ_7745c5c3_Var49 string
955 templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingGroupTitle(group))956 templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingGroupTitle(group))
956 if templ_7745c5c3_Err != nil {957 if templ_7745c5c3_Err != nil {
957- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 147, Col: 37}958+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 148, Col: 37}
958 }959 }
959 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))960 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
960 if templ_7745c5c3_Err != nil {961 if templ_7745c5c3_Err != nil {
@@ -972,7 +973,7 @@ func ConfigKeybindingGroup(group renderedKeybindingGroup) templ.Component {
972 var templ_7745c5c3_Var50 string973 var templ_7745c5c3_Var50 string
973 templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingGroupMeta(group))974 templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingGroupMeta(group))
974 if templ_7745c5c3_Err != nil {975 if templ_7745c5c3_Err != nil {
975- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 149, Col: 52}976+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 150, Col: 52}
976 }977 }
977 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))978 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
978 if templ_7745c5c3_Err != nil {979 if templ_7745c5c3_Err != nil {
@@ -1044,7 +1045,7 @@ func ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding)
1044 var templ_7745c5c3_Var52 string1045 var templ_7745c5c3_Var52 string
1045 templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingDescription(binding))1046 templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingDescription(binding))
1046 if templ_7745c5c3_Err != nil {1047 if templ_7745c5c3_Err != nil {
1047- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 171, Col: 44}1048+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 172, Col: 44}
1048 }1049 }
1049 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))1050 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
1050 if templ_7745c5c3_Err != nil {1051 if templ_7745c5c3_Err != nil {
@@ -1057,7 +1058,7 @@ func ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding)
1057 var templ_7745c5c3_Var53 string1058 var templ_7745c5c3_Var53 string
1058 templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingAction(binding))1059 templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingAction(binding))
1059 if templ_7745c5c3_Err != nil {1060 if templ_7745c5c3_Err != nil {
1060- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 172, Col: 50}1061+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 173, Col: 50}
1061 }1062 }
1062 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))1063 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
1063 if templ_7745c5c3_Err != nil {1064 if templ_7745c5c3_Err != nil {
@@ -1078,7 +1079,7 @@ func ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding)
1078 var templ_7745c5c3_Var54 string1079 var templ_7745c5c3_Var54 string
1079 templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingStatus(binding))1080 templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingStatus(binding))
1080 if templ_7745c5c3_Err != nil {1081 if templ_7745c5c3_Err != nil {
1081- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 175, Col: 55}1082+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 176, Col: 55}
1082 }1083 }
1083 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))1084 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
1084 if templ_7745c5c3_Err != nil {1085 if templ_7745c5c3_Err != nil {
@@ -1105,7 +1106,7 @@ func ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding)
1105 var templ_7745c5c3_Var55 string1106 var templ_7745c5c3_Var55 string
1106 templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(group.Mode)1107 templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(group.Mode)
1107 if templ_7745c5c3_Err != nil {1108 if templ_7745c5c3_Err != nil {
1108- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 183, Col: 55}1109+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 184, Col: 55}
1109 }1110 }
1110 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))1111 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
1111 if templ_7745c5c3_Err != nil {1112 if templ_7745c5c3_Err != nil {
@@ -1118,7 +1119,7 @@ func ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding)
1118 var templ_7745c5c3_Var56 string1119 var templ_7745c5c3_Var56 string
1119 templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(binding.Action)1120 templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(binding.Action)
1120 if templ_7745c5c3_Err != nil {1121 if templ_7745c5c3_Err != nil {
1121- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 184, Col: 61}1122+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 185, Col: 61}
1122 }1123 }
1123 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))1124 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
1124 if templ_7745c5c3_Err != nil {1125 if templ_7745c5c3_Err != nil {
@@ -1131,7 +1132,7 @@ func ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding)
1131 var templ_7745c5c3_Var57 string1132 var templ_7745c5c3_Var57 string
1132 templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingKeysValue(binding.Keys))1133 templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingKeysValue(binding.Keys))
1133 if templ_7745c5c3_Err != nil {1134 if templ_7745c5c3_Err != nil {
1134- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 185, Col: 100}1135+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 186, Col: 100}
1135 }1136 }
1136 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))1137 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))
1137 if templ_7745c5c3_Err != nil {1138 if templ_7745c5c3_Err != nil {
@@ -1144,7 +1145,7 @@ func ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding)
1144 var templ_7745c5c3_Var58 string1145 var templ_7745c5c3_Var58 string
1145 templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(group.Mode)1146 templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(group.Mode)
1146 if templ_7745c5c3_Err != nil {1147 if templ_7745c5c3_Err != nil {
1147- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 187, Col: 127}1148+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 188, Col: 127}
1148 }1149 }
1149 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58))1150 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58))
1150 if templ_7745c5c3_Err != nil {1151 if templ_7745c5c3_Err != nil {
@@ -1157,7 +1158,7 @@ func ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding)
1157 var templ_7745c5c3_Var59 string1158 var templ_7745c5c3_Var59 string
1158 templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(binding.Action)1159 templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(binding.Action)
1159 if templ_7745c5c3_Err != nil {1160 if templ_7745c5c3_Err != nil {
1160- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 187, Col: 158}1161+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 188, Col: 158}
1161 }1162 }
1162 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59))1163 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59))
1163 if templ_7745c5c3_Err != nil {1164 if templ_7745c5c3_Err != nil {
M internal/ui/systemviews/config_view.go +11 -5
@@ -16,6 +16,8 @@ type configRenderData struct {
1616 Error string
1717 }
1818
19+const maxFlattenDepth = 16
20+
1921 func configHTML(data configRenderData) string {
2022 return mustRenderComponent(ConfigView(data))
2123 }
@@ -140,15 +142,19 @@ func engineIsWebKit(engine string) bool {
140142
141143 func flattenValues(value any) []kvPair {
142144 rows := make([]kvPair, 0)
143- flattenValue(&rows, "", reflect.ValueOf(value))
145+ flattenValue(&rows, "", reflect.ValueOf(value), 0)
144146 return rows
145147 }
146148
147-func flattenValue(rows *[]kvPair, path string, value reflect.Value) {
149+func flattenValue(rows *[]kvPair, path string, value reflect.Value, depth int) {
148150 if !value.IsValid() {
149151 appendFlatValue(rows, path, "null")
150152 return
151153 }
154+ if depth >= maxFlattenDepth {
155+ appendFlatValue(rows, path, "...")
156+ return
157+ }
152158
153159 for value.Kind() == reflect.Interface {
154160 if value.IsNil() {
@@ -182,7 +188,7 @@ func flattenValue(rows *[]kvPair, path string, value reflect.Value) {
182188 if path != "" {
183189 nextPath = path + "." + name
184190 }
185- flattenValue(rows, nextPath, value.Field(i))
191+ flattenValue(rows, nextPath, value.Field(i), depth+1)
186192 }
187193 case reflect.Map:
188194 if value.Len() == 0 {
@@ -201,7 +207,7 @@ func flattenValue(rows *[]kvPair, path string, value reflect.Value) {
201207 if path != "" {
202208 nextPath = path + "." + name
203209 }
204- flattenValue(rows, nextPath, value.MapIndex(key))
210+ flattenValue(rows, nextPath, value.MapIndex(key), depth+1)
205211 }
206212 case reflect.Slice, reflect.Array:
207213 if value.Len() == 0 {
@@ -214,7 +220,7 @@ func flattenValue(rows *[]kvPair, path string, value reflect.Value) {
214220 if path == "" {
215221 nextPath = fmt.Sprintf("[%d]", i)
216222 }
217- flattenValue(rows, nextPath, value.Index(i))
223+ flattenValue(rows, nextPath, value.Index(i), depth+1)
218224 }
219225 default:
220226 appendFlatValue(rows, path, formatFlatValue(value))
@@ -16,6 +16,8 @@ type configRenderData struct {
16 Error string16 Error string
17 }17 }
18 18
19+const maxFlattenDepth = 16
20+
19 func configHTML(data configRenderData) string {21 func configHTML(data configRenderData) string {
20 return mustRenderComponent(ConfigView(data))22 return mustRenderComponent(ConfigView(data))
21 }23 }
@@ -140,15 +142,19 @@ func engineIsWebKit(engine string) bool {
140 142
141 func flattenValues(value any) []kvPair {143 func flattenValues(value any) []kvPair {
142 rows := make([]kvPair, 0)144 rows := make([]kvPair, 0)
143- flattenValue(&rows, "", reflect.ValueOf(value))145+ flattenValue(&rows, "", reflect.ValueOf(value), 0)
144 return rows146 return rows
145 }147 }
146 148
147-func flattenValue(rows *[]kvPair, path string, value reflect.Value) {149+func flattenValue(rows *[]kvPair, path string, value reflect.Value, depth int) {
148 if !value.IsValid() {150 if !value.IsValid() {
149 appendFlatValue(rows, path, "null")151 appendFlatValue(rows, path, "null")
150 return152 return
151 }153 }
154+ if depth >= maxFlattenDepth {
155+ appendFlatValue(rows, path, "...")
156+ return
157+ }
152 158
153 for value.Kind() == reflect.Interface {159 for value.Kind() == reflect.Interface {
154 if value.IsNil() {160 if value.IsNil() {
@@ -182,7 +188,7 @@ func flattenValue(rows *[]kvPair, path string, value reflect.Value) {
182 if path != "" {188 if path != "" {
183 nextPath = path + "." + name189 nextPath = path + "." + name
184 }190 }
185- flattenValue(rows, nextPath, value.Field(i))191+ flattenValue(rows, nextPath, value.Field(i), depth+1)
186 }192 }
187 case reflect.Map:193 case reflect.Map:
188 if value.Len() == 0 {194 if value.Len() == 0 {
@@ -201,7 +207,7 @@ func flattenValue(rows *[]kvPair, path string, value reflect.Value) {
201 if path != "" {207 if path != "" {
202 nextPath = path + "." + name208 nextPath = path + "." + name
203 }209 }
204- flattenValue(rows, nextPath, value.MapIndex(key))210+ flattenValue(rows, nextPath, value.MapIndex(key), depth+1)
205 }211 }
206 case reflect.Slice, reflect.Array:212 case reflect.Slice, reflect.Array:
207 if value.Len() == 0 {213 if value.Len() == 0 {
@@ -214,7 +220,7 @@ func flattenValue(rows *[]kvPair, path string, value reflect.Value) {
214 if path == "" {220 if path == "" {
215 nextPath = fmt.Sprintf("[%d]", i)221 nextPath = fmt.Sprintf("[%d]", i)
216 }222 }
217- flattenValue(rows, nextPath, value.Index(i))223+ flattenValue(rows, nextPath, value.Index(i), depth+1)
218 }224 }
219 default:225 default:
220 appendFlatValue(rows, path, formatFlatValue(value))226 appendFlatValue(rows, path, formatFlatValue(value))
M internal/ui/systemviews/dom_js.go +51 -60
@@ -12,7 +12,13 @@ import (
1212 type browserDOM struct {
1313 document js.Value
1414 target js.Value
15- bindings []js.Func
15+ bindings []jsEventBinding
16+}
17+
18+type jsEventBinding struct {
19+ target js.Value
20+ event string
21+ fn js.Func
1622 }
1723
1824 func NewDOM() DOM {
@@ -41,6 +47,7 @@ func (d *browserDOM) BindActions(handler DOMActionHandler) error {
4147 if d == nil || !d.target.Truthy() || handler == nil {
4248 return fmt.Errorf("DOM action binding target not available")
4349 }
50+ d.Release()
4451
4552 clickHandler := js.FuncOf(func(_ js.Value, args []js.Value) any {
4653 event := firstJSArg(args)
@@ -62,8 +69,7 @@ func (d *browserDOM) BindActions(handler DOMActionHandler) error {
6269 handler(DOMAction{Action: action, Data: collectActionData(element)})
6370 return nil
6471 })
65- d.target.Call("addEventListener", "click", clickHandler)
66- d.bindings = append(d.bindings, clickHandler)
72+ d.addEventBinding(d.target, "click", clickHandler)
6773
6874 submitHandler := js.FuncOf(func(_ js.Value, args []js.Value) any {
6975 event := firstJSArg(args)
@@ -86,19 +92,39 @@ func (d *browserDOM) BindActions(handler DOMActionHandler) error {
8692 handler(DOMAction{Action: action, Data: data})
8793 return nil
8894 })
89- d.target.Call("addEventListener", "submit", submitHandler)
90- d.bindings = append(d.bindings, submitHandler)
95+ d.addEventBinding(d.target, "submit", submitHandler)
9196
9297 keydownHandler := js.FuncOf(func(_ js.Value, args []js.Value) any {
9398 d.handleKeyboard(firstJSArg(args))
9499 return nil
95100 })
96- d.document.Call("addEventListener", "keydown", keydownHandler)
97- d.bindings = append(d.bindings, keydownHandler)
101+ d.addEventBinding(d.document, "keydown", keydownHandler)
98102
99103 return nil
100104 }
101105
106+func (d *browserDOM) addEventBinding(target js.Value, event string, fn js.Func) {
107+ if !target.Truthy() {
108+ fn.Release()
109+ return
110+ }
111+ target.Call("addEventListener", event, fn)
112+ d.bindings = append(d.bindings, jsEventBinding{target: target, event: event, fn: fn})
113+}
114+
115+func (d *browserDOM) Release() {
116+ if d == nil {
117+ return
118+ }
119+ for _, binding := range d.bindings {
120+ if binding.target.Truthy() {
121+ binding.target.Call("removeEventListener", binding.event, binding.fn)
122+ }
123+ binding.fn.Release()
124+ }
125+ d.bindings = nil
126+}
127+
102128 func firstJSArg(args []js.Value) js.Value {
103129 if len(args) == 0 {
104130 return js.Value{}
@@ -278,63 +304,28 @@ func isEditableTarget(target js.Value) bool {
278304 }
279305
280306 func (d *browserDOM) focusHistoryRow(delta int) {
281- rows := d.target.Call("querySelectorAll", "[data-sv-history-row]")
282- length := rows.Get("length").Int()
283- if length == 0 {
284- return
285- }
286- idx := d.activeHistoryIndex()
287- if idx < 0 {
288- idx = 0
289- } else {
290- idx += delta
291- }
292- if idx < 0 {
293- idx = 0
294- }
295- if idx >= length {
296- idx = length - 1
297- }
298- for i := 0; i < length; i++ {
299- rows.Index(i).Get("classList").Call("remove", "sv-focused")
300- }
301- row := rows.Index(idx)
302- row.Get("classList").Call("add", "sv-focused")
303- d.target.Get("dataset").Set("svActiveHistoryIndex", strconv.Itoa(idx))
304- row.Call("scrollIntoView", map[string]any{"block": "nearest"})
307+ d.focusRow("[data-sv-history-row]", "svActiveHistoryIndex", delta)
305308 }
306309
307310 func (d *browserDOM) activeHistoryRow() js.Value {
308- rows := d.target.Call("querySelectorAll", "[data-sv-history-row]")
309- length := rows.Get("length").Int()
310- if length == 0 {
311- return js.Value{}
312- }
313- idx := d.activeHistoryIndex()
314- if idx < 0 || idx >= length {
315- idx = 0
316- d.target.Get("dataset").Set("svActiveHistoryIndex", "0")
317- rows.Index(0).Get("classList").Call("add", "sv-focused")
318- }
319- return rows.Index(idx)
311+ return d.activeRow("[data-sv-history-row]", "svActiveHistoryIndex")
320312 }
321313
322-func (d *browserDOM) activeHistoryIndex() int {
323- value := d.target.Get("dataset").Get("svActiveHistoryIndex").String()
324- idx, err := strconv.Atoi(value)
325- if err != nil {
326- return -1
327- }
328- return idx
314+func (d *browserDOM) focusFavoriteRow(delta int) {
315+ d.focusRow("[data-sv-favorite-row]", "svActiveFavoriteIndex", delta)
329316 }
330317
331-func (d *browserDOM) focusFavoriteRow(delta int) {
332- rows := d.target.Call("querySelectorAll", "[data-sv-favorite-row]")
318+func (d *browserDOM) activeFavoriteRow() js.Value {
319+ return d.activeRow("[data-sv-favorite-row]", "svActiveFavoriteIndex")
320+}
321+
322+func (d *browserDOM) focusRow(selector, datasetKey string, delta int) {
323+ rows := d.target.Call("querySelectorAll", selector)
333324 length := rows.Get("length").Int()
334325 if length == 0 {
335326 return
336327 }
337- idx := d.activeFavoriteIndex()
328+ idx := d.activeIndex(datasetKey)
338329 if idx < 0 {
339330 idx = 0
340331 } else {
@@ -351,27 +342,27 @@ func (d *browserDOM) focusFavoriteRow(delta int) {
351342 }
352343 row := rows.Index(idx)
353344 row.Get("classList").Call("add", "sv-focused")
354- d.target.Get("dataset").Set("svActiveFavoriteIndex", strconv.Itoa(idx))
345+ d.target.Get("dataset").Set(datasetKey, strconv.Itoa(idx))
355346 row.Call("scrollIntoView", map[string]any{"block": "nearest"})
356347 }
357348
358-func (d *browserDOM) activeFavoriteRow() js.Value {
359- rows := d.target.Call("querySelectorAll", "[data-sv-favorite-row]")
349+func (d *browserDOM) activeRow(selector, datasetKey string) js.Value {
350+ rows := d.target.Call("querySelectorAll", selector)
360351 length := rows.Get("length").Int()
361352 if length == 0 {
362353 return js.Value{}
363354 }
364- idx := d.activeFavoriteIndex()
355+ idx := d.activeIndex(datasetKey)
365356 if idx < 0 || idx >= length {
366357 idx = 0
367- d.target.Get("dataset").Set("svActiveFavoriteIndex", "0")
358+ d.target.Get("dataset").Set(datasetKey, "0")
368359 rows.Index(0).Get("classList").Call("add", "sv-focused")
369360 }
370361 return rows.Index(idx)
371362 }
372363
373-func (d *browserDOM) activeFavoriteIndex() int {
374- value := d.target.Get("dataset").Get("svActiveFavoriteIndex").String()
364+func (d *browserDOM) activeIndex(datasetKey string) int {
365+ value := d.target.Get("dataset").Get(datasetKey).String()
375366 idx, err := strconv.Atoi(value)
376367 if err != nil {
377368 return -1
@@ -12,7 +12,13 @@ import (
12 type browserDOM struct {12 type browserDOM struct {
13 document js.Value13 document js.Value
14 target js.Value14 target js.Value
15- bindings []js.Func15+ bindings []jsEventBinding
16+}
17+
18+type jsEventBinding struct {
19+ target js.Value
20+ event string
21+ fn js.Func
16 }22 }
17 23
18 func NewDOM() DOM {24 func NewDOM() DOM {
@@ -41,6 +47,7 @@ func (d *browserDOM) BindActions(handler DOMActionHandler) error {
41 if d == nil || !d.target.Truthy() || handler == nil {47 if d == nil || !d.target.Truthy() || handler == nil {
42 return fmt.Errorf("DOM action binding target not available")48 return fmt.Errorf("DOM action binding target not available")
43 }49 }
50+ d.Release()
44 51
45 clickHandler := js.FuncOf(func(_ js.Value, args []js.Value) any {52 clickHandler := js.FuncOf(func(_ js.Value, args []js.Value) any {
46 event := firstJSArg(args)53 event := firstJSArg(args)
@@ -62,8 +69,7 @@ func (d *browserDOM) BindActions(handler DOMActionHandler) error {
62 handler(DOMAction{Action: action, Data: collectActionData(element)})69 handler(DOMAction{Action: action, Data: collectActionData(element)})
63 return nil70 return nil
64 })71 })
65- d.target.Call("addEventListener", "click", clickHandler)72+ d.addEventBinding(d.target, "click", clickHandler)
66- d.bindings = append(d.bindings, clickHandler)
67 73
68 submitHandler := js.FuncOf(func(_ js.Value, args []js.Value) any {74 submitHandler := js.FuncOf(func(_ js.Value, args []js.Value) any {
69 event := firstJSArg(args)75 event := firstJSArg(args)
@@ -86,19 +92,39 @@ func (d *browserDOM) BindActions(handler DOMActionHandler) error {
86 handler(DOMAction{Action: action, Data: data})92 handler(DOMAction{Action: action, Data: data})
87 return nil93 return nil
88 })94 })
89- d.target.Call("addEventListener", "submit", submitHandler)95+ d.addEventBinding(d.target, "submit", submitHandler)
90- d.bindings = append(d.bindings, submitHandler)
91 96
92 keydownHandler := js.FuncOf(func(_ js.Value, args []js.Value) any {97 keydownHandler := js.FuncOf(func(_ js.Value, args []js.Value) any {
93 d.handleKeyboard(firstJSArg(args))98 d.handleKeyboard(firstJSArg(args))
94 return nil99 return nil
95 })100 })
96- d.document.Call("addEventListener", "keydown", keydownHandler)101+ d.addEventBinding(d.document, "keydown", keydownHandler)
97- d.bindings = append(d.bindings, keydownHandler)
98 102
99 return nil103 return nil
100 }104 }
101 105
106+func (d *browserDOM) addEventBinding(target js.Value, event string, fn js.Func) {
107+ if !target.Truthy() {
108+ fn.Release()
109+ return
110+ }
111+ target.Call("addEventListener", event, fn)
112+ d.bindings = append(d.bindings, jsEventBinding{target: target, event: event, fn: fn})
113+}
114+
115+func (d *browserDOM) Release() {
116+ if d == nil {
117+ return
118+ }
119+ for _, binding := range d.bindings {
120+ if binding.target.Truthy() {
121+ binding.target.Call("removeEventListener", binding.event, binding.fn)
122+ }
123+ binding.fn.Release()
124+ }
125+ d.bindings = nil
126+}
127+
102 func firstJSArg(args []js.Value) js.Value {128 func firstJSArg(args []js.Value) js.Value {
103 if len(args) == 0 {129 if len(args) == 0 {
104 return js.Value{}130 return js.Value{}
@@ -278,63 +304,28 @@ func isEditableTarget(target js.Value) bool {
278 }304 }
279 305
280 func (d *browserDOM) focusHistoryRow(delta int) {306 func (d *browserDOM) focusHistoryRow(delta int) {
281- rows := d.target.Call("querySelectorAll", "[data-sv-history-row]")307+ d.focusRow("[data-sv-history-row]", "svActiveHistoryIndex", delta)
282- length := rows.Get("length").Int()
283- if length == 0 {
284- return
285- }
286- idx := d.activeHistoryIndex()
287- if idx < 0 {
288- idx = 0
289- } else {
290- idx += delta
291- }
292- if idx < 0 {
293- idx = 0
294- }
295- if idx >= length {
296- idx = length - 1
297- }
298- for i := 0; i < length; i++ {
299- rows.Index(i).Get("classList").Call("remove", "sv-focused")
300- }
301- row := rows.Index(idx)
302- row.Get("classList").Call("add", "sv-focused")
303- d.target.Get("dataset").Set("svActiveHistoryIndex", strconv.Itoa(idx))
304- row.Call("scrollIntoView", map[string]any{"block": "nearest"})
305 }308 }
306 309
307 func (d *browserDOM) activeHistoryRow() js.Value {310 func (d *browserDOM) activeHistoryRow() js.Value {
308- rows := d.target.Call("querySelectorAll", "[data-sv-history-row]")311+ return d.activeRow("[data-sv-history-row]", "svActiveHistoryIndex")
309- length := rows.Get("length").Int()
310- if length == 0 {
311- return js.Value{}
312- }
313- idx := d.activeHistoryIndex()
314- if idx < 0 || idx >= length {
315- idx = 0
316- d.target.Get("dataset").Set("svActiveHistoryIndex", "0")
317- rows.Index(0).Get("classList").Call("add", "sv-focused")
318- }
319- return rows.Index(idx)
320 }312 }
321 313
322-func (d *browserDOM) activeHistoryIndex() int {314+func (d *browserDOM) focusFavoriteRow(delta int) {
323- value := d.target.Get("dataset").Get("svActiveHistoryIndex").String()315+ d.focusRow("[data-sv-favorite-row]", "svActiveFavoriteIndex", delta)
324- idx, err := strconv.Atoi(value)
325- if err != nil {
326- return -1
327- }
328- return idx
329 }316 }
330 317
331-func (d *browserDOM) focusFavoriteRow(delta int) {318+func (d *browserDOM) activeFavoriteRow() js.Value {
332- rows := d.target.Call("querySelectorAll", "[data-sv-favorite-row]")319+ return d.activeRow("[data-sv-favorite-row]", "svActiveFavoriteIndex")
320+}
321+
322+func (d *browserDOM) focusRow(selector, datasetKey string, delta int) {
323+ rows := d.target.Call("querySelectorAll", selector)
333 length := rows.Get("length").Int()324 length := rows.Get("length").Int()
334 if length == 0 {325 if length == 0 {
335 return326 return
336 }327 }
337- idx := d.activeFavoriteIndex()328+ idx := d.activeIndex(datasetKey)
338 if idx < 0 {329 if idx < 0 {
339 idx = 0330 idx = 0
340 } else {331 } else {
@@ -351,27 +342,27 @@ func (d *browserDOM) focusFavoriteRow(delta int) {
351 }342 }
352 row := rows.Index(idx)343 row := rows.Index(idx)
353 row.Get("classList").Call("add", "sv-focused")344 row.Get("classList").Call("add", "sv-focused")
354- d.target.Get("dataset").Set("svActiveFavoriteIndex", strconv.Itoa(idx))345+ d.target.Get("dataset").Set(datasetKey, strconv.Itoa(idx))
355 row.Call("scrollIntoView", map[string]any{"block": "nearest"})346 row.Call("scrollIntoView", map[string]any{"block": "nearest"})
356 }347 }
357 348
358-func (d *browserDOM) activeFavoriteRow() js.Value {349+func (d *browserDOM) activeRow(selector, datasetKey string) js.Value {
359- rows := d.target.Call("querySelectorAll", "[data-sv-favorite-row]")350+ rows := d.target.Call("querySelectorAll", selector)
360 length := rows.Get("length").Int()351 length := rows.Get("length").Int()
361 if length == 0 {352 if length == 0 {
362 return js.Value{}353 return js.Value{}
363 }354 }
364- idx := d.activeFavoriteIndex()355+ idx := d.activeIndex(datasetKey)
365 if idx < 0 || idx >= length {356 if idx < 0 || idx >= length {
366 idx = 0357 idx = 0
367- d.target.Get("dataset").Set("svActiveFavoriteIndex", "0")358+ d.target.Get("dataset").Set(datasetKey, "0")
368 rows.Index(0).Get("classList").Call("add", "sv-focused")359 rows.Index(0).Get("classList").Call("add", "sv-focused")
369 }360 }
370 return rows.Index(idx)361 return rows.Index(idx)
371 }362 }
372 363
373-func (d *browserDOM) activeFavoriteIndex() int {364+func (d *browserDOM) activeIndex(datasetKey string) int {
374- value := d.target.Get("dataset").Get("svActiveFavoriteIndex").String()365+ value := d.target.Get("dataset").Get(datasetKey).String()
375 idx, err := strconv.Atoi(value)366 idx, err := strconv.Atoi(value)
376 if err != nil {367 if err != nil {
377 return -1368 return -1
M internal/ui/systemviews/favorite_actions.go +11 -2
@@ -81,7 +81,7 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {
8181 }
8282 a.favoritesNotice = "Deleted favorite"
8383 case favoriteActionFilterFolder:
84- folderID, err := filterFolderID(data["folder_id"])
84+ folderID, err := filterFolderID(firstActionValue(data, "folderId", "folder_id"))
8585 if err != nil {
8686 return err
8787 }
@@ -89,7 +89,7 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {
8989 a.favoriteTagFilter = nil
9090 a.favoritesNotice = ""
9191 case favoriteActionFilterTag:
92- tagID, err := parsePositiveInt64(data["tag_id"], "tag id")
92+ tagID, err := parsePositiveInt64(firstActionValue(data, "tagId", "tag_id"), "tag id")
9393 if err != nil {
9494 return err
9595 }
@@ -191,6 +191,15 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {
191191 return nil
192192 }
193193
194+func firstActionValue(data map[string]string, keys ...string) string {
195+ for _, key := range keys {
196+ if value, ok := data[key]; ok {
197+ return value
198+ }
199+ }
200+ return ""
201+}
202+
194203 func parsePositiveInt64(raw, label string) (int64, error) {
195204 id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
196205 if err != nil || id <= 0 {
@@ -81,7 +81,7 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {
81 }81 }
82 a.favoritesNotice = "Deleted favorite"82 a.favoritesNotice = "Deleted favorite"
83 case favoriteActionFilterFolder:83 case favoriteActionFilterFolder:
84- folderID, err := filterFolderID(data["folder_id"])84+ folderID, err := filterFolderID(firstActionValue(data, "folderId", "folder_id"))
85 if err != nil {85 if err != nil {
86 return err86 return err
87 }87 }
@@ -89,7 +89,7 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {
89 a.favoriteTagFilter = nil89 a.favoriteTagFilter = nil
90 a.favoritesNotice = ""90 a.favoritesNotice = ""
91 case favoriteActionFilterTag:91 case favoriteActionFilterTag:
92- tagID, err := parsePositiveInt64(data["tag_id"], "tag id")92+ tagID, err := parsePositiveInt64(firstActionValue(data, "tagId", "tag_id"), "tag id")
93 if err != nil {93 if err != nil {
94 return err94 return err
95 }95 }
@@ -191,6 +191,15 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {
191 return nil191 return nil
192 }192 }
193 193
194+func firstActionValue(data map[string]string, keys ...string) string {
195+ for _, key := range keys {
196+ if value, ok := data[key]; ok {
197+ return value
198+ }
199+ }
200+ return ""
201+}
202+
194 func parsePositiveInt64(raw, label string) (int64, error) {203 func parsePositiveInt64(raw, label string) (int64, error) {
195 id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)204 id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
196 if err != nil || id <= 0 {205 if err != nil || id <= 0 {
M internal/ui/systemviews/history_actions.go +3 -0
@@ -114,6 +114,9 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error {
114114 a.historyNotice = "Deleted history entry"
115115 case historyActionDeleteRange:
116116 rangeID := strings.TrimSpace(data["range"])
117+ if rangeID == "" {
118+ return fmt.Errorf("history range is required")
119+ }
117120 if err := a.deps.History.DeleteRange(ctx, rangeID); err != nil {
118121 return err
119122 }
@@ -114,6 +114,9 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error {
114 a.historyNotice = "Deleted history entry"114 a.historyNotice = "Deleted history entry"
115 case historyActionDeleteRange:115 case historyActionDeleteRange:
116 rangeID := strings.TrimSpace(data["range"])116 rangeID := strings.TrimSpace(data["range"])
117+ if rangeID == "" {
118+ return fmt.Errorf("history range is required")
119+ }
117 if err := a.deps.History.DeleteRange(ctx, rangeID); err != nil {120 if err := a.deps.History.DeleteRange(ctx, rangeID); err != nil {
118 return err121 return err
119 }122 }
M internal/ui/systemviews/history_view.go +2 -2
@@ -220,7 +220,7 @@ func historyDomainActionKey(domain *entity.DomainStat) string {
220220 if domain == nil {
221221 return ""
222222 }
223- return strings.TrimSpace(domain.Domain)
223+ return browserurl.CanonicalDomain(domain.Domain)
224224 }
225225
226226 func historyDomainDisplayLabel(domain *entity.DomainStat) string {
@@ -266,7 +266,7 @@ func historyItemMeta(entry *entity.HistoryEntry) string {
266266 }
267267
268268 func nonEmptyStrings(values []string) []string {
269- out := values[:0]
269+ out := make([]string, 0, len(values))
270270 for _, value := range values {
271271 if strings.TrimSpace(value) != "" {
272272 out = append(out, value)
@@ -220,7 +220,7 @@ func historyDomainActionKey(domain *entity.DomainStat) string {
220 if domain == nil {220 if domain == nil {
221 return ""221 return ""
222 }222 }
223- return strings.TrimSpace(domain.Domain)223+ return browserurl.CanonicalDomain(domain.Domain)
224 }224 }
225 225
226 func historyDomainDisplayLabel(domain *entity.DomainStat) string {226 func historyDomainDisplayLabel(domain *entity.DomainStat) string {
@@ -266,7 +266,7 @@ func historyItemMeta(entry *entity.HistoryEntry) string {
266 }266 }
267 267
268 func nonEmptyStrings(values []string) []string {268 func nonEmptyStrings(values []string) []string {
269- out := values[:0]269+ out := make([]string, 0, len(values))
270 for _, value := range values {270 for _, value := range values {
271 if strings.TrimSpace(value) != "" {271 if strings.TrimSpace(value) != "" {
272 out = append(out, value)272 out = append(out, value)
M internal/ui/systemviews/shell.go +3 -3
@@ -63,11 +63,11 @@ func alertRole(kind string) string {
6363 }
6464
6565 func buttonClass(extra string) string {
66- classes := strings.TrimSpace("sv-button " + strings.TrimSpace(extra))
67- if classes == "" {
66+ extra = strings.TrimSpace(extra)
67+ if extra == "" {
6868 return "sv-button"
6969 }
70- return classes
70+ return "sv-button " + extra
7171 }
7272
7373 func listHTML(rows, emptyMessage string) string {
@@ -63,11 +63,11 @@ func alertRole(kind string) string {
63 }63 }
64 64
65 func buttonClass(extra string) string {65 func buttonClass(extra string) string {
66- classes := strings.TrimSpace("sv-button " + strings.TrimSpace(extra))66+ extra = strings.TrimSpace(extra)
67- if classes == "" {67+ if extra == "" {
68 return "sv-button"68 return "sv-button"
69 }69 }
70- return classes70+ return "sv-button " + extra
71 }71 }
72 72
73 func listHTML(rows, emptyMessage string) string {73 func listHTML(rows, emptyMessage string) string {
M internal/ui/systemviews/theme_test.go +3 -2
@@ -56,9 +56,10 @@ func TestResolveShellTheme(t *testing.T) {
5656 for _, tt := range tests {
5757 t.Run(tt.name, func(t *testing.T) {
5858 currentPrefersDarkImpl = func() bool { return tt.prefersDark }
59- appearance.ColorScheme = tt.colorScheme
59+ ttAppearance := appearance
60+ ttAppearance.ColorScheme = tt.colorScheme
6061
61- got := resolveShellTheme(appearance)
62+ got := resolveShellTheme(ttAppearance)
6263
6364 assert.Equal(t, tt.wantClass, got.RootClass)
6465 assert.Contains(t, got.InlineVars, "--sv-background")
@@ -56,9 +56,10 @@ func TestResolveShellTheme(t *testing.T) {
56 for _, tt := range tests {56 for _, tt := range tests {
57 t.Run(tt.name, func(t *testing.T) {57 t.Run(tt.name, func(t *testing.T) {
58 currentPrefersDarkImpl = func() bool { return tt.prefersDark }58 currentPrefersDarkImpl = func() bool { return tt.prefersDark }
59- appearance.ColorScheme = tt.colorScheme59+ ttAppearance := appearance
60+ ttAppearance.ColorScheme = tt.colorScheme
60 61
61- got := resolveShellTheme(appearance)62+ got := resolveShellTheme(ttAppearance)
62 63
63 assert.Equal(t, tt.wantClass, got.RootClass)64 assert.Equal(t, tt.wantClass, got.RootClass)
64 assert.Contains(t, got.InlineVars, "--sv-background")65 assert.Contains(t, got.InlineVars, "--sv-background")

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