fix(systemviews): address review feedback
- commit
- 279f5af49f1217c797bc49eb4eb87150a40966c6
- author
- brice · · 2026-04-24 08:18 UTC
- parent
- 1ed380e
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 56 | 56 | @mkdir -p assets/systemviews | |
| 57 | 57 | @cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" assets/systemviews/wasm_exec.js | |
| 58 | 58 | 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 | 60 | @echo "Systemviews build complete" | |
| 60 | 61 | ||
| 61 | 62 | build-quick: ## Build quickly for backend development | |
| @@ -56,6 +56,7 @@ build-systemviews: generate-systemviews ## Build the WASM systemviews runtime | |||||
| 56 | @mkdir -p assets/systemviews | 56 | @mkdir -p assets/systemviews | ||
| 57 | @cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" assets/systemviews/wasm_exec.js | 57 | @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/systemviews | 58 | 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 development | 62 | build-quick: ## Build quickly for backend development | ||
M assets/systemviews/index.html +16 -1
| @@ -10,7 +10,22 @@ | |||
| 10 | 10 | <script> | |
| 11 | 11 | const go = new Go(); | |
| 12 | 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 | 29 | </script> | |
| 15 | 30 | </head> | |
| 16 | 31 | <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)" | |||
| 6 | 6 | arch=('x86_64') | |
| 7 | 7 | url="https://github.com/bnema/dumber" | |
| 8 | 8 | license=('MIT') | |
| 9 | - | makedepends=('go>=1.25' 'git') | |
| 9 | + | makedepends=('go>=1.26' 'git') | |
| 10 | 10 | depends=('gtk4' 'webkitgtk-6.0') | |
| 11 | 11 | optdepends=( | |
| 12 | 12 | '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 { | |||
| 96 | 96 | return nil | |
| 97 | 97 | } | |
| 98 | 98 | ||
| 99 | + | // Handwritten fake to capture bridge state for stateful render assertions. | |
| 99 | 100 | type fakeBridgeService struct { | |
| 100 | 101 | calledHistory bool | |
| 101 | 102 | calledFavorites bool | |
| @@ -115,6 +116,11 @@ func (f *fakeBridgeService) Timeline(context.Context, int, int) ([]*entity.Histo | |||
| 115 | 116 | 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 | 124 | func (*fakeBridgeService) Search(context.Context, string, int) ([]*entity.HistoryEntry, error) { | |
| 119 | 125 | return nil, nil | |
| 120 | 126 | } | |
| @@ -96,6 +96,7 @@ func (f *fakeDOM) Mount(html string) error { | |||||
| 96 | return nil | 96 | 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 bool | 101 | calledHistory bool | ||
| 101 | calledFavorites bool | 102 | calledFavorites bool | ||
| @@ -115,6 +116,11 @@ func (f *fakeBridgeService) Timeline(context.Context, int, int) ([]*entity.Histo | |||||
| 115 | return f.historyEntries, nil | 116 | 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, nil | 125 | return nil, nil | ||
| 120 | } | 126 | } | ||
M cmd/systemviews/main.go +6 -1
| @@ -11,7 +11,12 @@ import ( | |||
| 11 | 11 | ||
| 12 | 12 | func main() { | |
| 13 | 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 | 21 | if err := app.Run(); err != nil { | |
| 17 | 22 | 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: | |||
| 80 | 80 | | Action | Keys | | |
| 81 | 81 | |--------|------| | |
| 82 | 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 | 84 | | Toggle Favorites system view in right split | unbound by default | | |
| 85 | 85 | | Toggle Config system view in right split | unbound by default | | |
| 86 | 86 | | Close pane (or release floating pane) | `Ctrl+W` | | |
| @@ -93,7 +93,7 @@ These work outside modal modes: | |||
| 93 | 93 | ||
| 94 | 94 | - `Alt+F` is the only floating-pane shortcut enabled by default. | |
| 95 | 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 | 97 | - `Ctrl+W` closes the active pane; when the floating pane is active, it fully releases that floating session. | |
| 98 | 98 | - Any URL shortcut (for example `Alt+G`) must be defined explicitly in `workspace.floating_pane.profiles`. | |
| 99 | 99 | - 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 { | |||
| 21 | 21 | // HomepageHistory provides history operations needed by the WebUI homepage handlers. | |
| 22 | 22 | type HomepageHistory interface { | |
| 23 | 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 | 25 | Search(ctx context.Context, input HistorySearchInput) (*HistorySearchOutput, error) | |
| 25 | 26 | Delete(ctx context.Context, id int64) error | |
| 26 | 27 | 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) error | 26 | Delete(ctx context.Context, id int64) error | ||
| 26 | ClearRange(ctx context.Context, rangeID string) error | 27 | 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 | |||
| 6490 | 6490 | return _c | |
| 6491 | 6491 | } | |
| 6492 | 6492 | ||
| 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) | |
| 6496 | 6496 | ||
| 6497 | 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 | 6501 | 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) | |
| 6504 | 6504 | } else { | |
| 6505 | 6505 | r0 = ret.Error(0) | |
| 6506 | 6506 | } | |
| 6507 | 6507 | 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' | |
| 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 { | |
| 6512 | 6512 | *mock.Call | |
| 6513 | 6513 | } | |
| 6514 | 6514 | ||
| 6515 | - | // ClearRange is a helper method to define mock.On call | |
| 6515 | + | // ClearOlderThan is a helper method to define mock.On call | |
| 6516 | 6516 | // - 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)} | |
| 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 | 6523 | _c.Call.Run(func(args mock.Arguments) { | |
| 6524 | 6524 | var arg0 context.Context | |
| 6525 | 6525 | if args[0] != nil { | |
| 6526 | 6526 | arg0 = args[0].(context.Context) | |
| 6527 | 6527 | } | |
| 6528 | - | var arg1 string | |
| 6528 | + | var arg1 time.Time | |
| 6529 | 6529 | if args[1] != nil { | |
| 6530 | - | arg1 = args[1].(string) | |
| 6530 | + | arg1 = args[1].(time.Time) | |
| 6531 | 6531 | } | |
| 6532 | 6532 | run( | |
| 6533 | 6533 | arg0, | |
| @@ -6537,54 +6537,54 @@ func (_c *MockHomepageHistory_ClearRange_Call) Run(run func(ctx context.Context, | |||
| 6537 | 6537 | 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 | 6541 | _c.Call.Return(err) | |
| 6542 | 6542 | 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 | 6546 | _c.Call.Return(run) | |
| 6547 | 6547 | return _c | |
| 6548 | 6548 | } | |
| 6549 | 6549 | ||
| 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) | |
| 6553 | 6553 | ||
| 6554 | 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 | 6558 | 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) | |
| 6561 | 6561 | } else { | |
| 6562 | 6562 | r0 = ret.Error(0) | |
| 6563 | 6563 | } | |
| 6564 | 6564 | 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' | |
| 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 { | |
| 6569 | 6569 | *mock.Call | |
| 6570 | 6570 | } | |
| 6571 | 6571 | ||
| 6572 | - | // ClearOlderThan is a helper method to define mock.On call | |
| 6572 | + | // ClearRange is a helper method to define mock.On call | |
| 6573 | 6573 | // - 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)} | |
| 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 | 6580 | _c.Call.Run(func(args mock.Arguments) { | |
| 6581 | 6581 | var arg0 context.Context | |
| 6582 | 6582 | if args[0] != nil { | |
| 6583 | 6583 | arg0 = args[0].(context.Context) | |
| 6584 | 6584 | } | |
| 6585 | - | var arg1 time.Time | |
| 6585 | + | var arg1 string | |
| 6586 | 6586 | if args[1] != nil { | |
| 6587 | - | arg1 = args[1].(time.Time) | |
| 6587 | + | arg1 = args[1].(string) | |
| 6588 | 6588 | } | |
| 6589 | 6589 | run( | |
| 6590 | 6590 | arg0, | |
| @@ -6594,12 +6594,12 @@ func (_c *MockHomepageHistory_ClearOlderThan_Call) Run(run func(ctx context.Cont | |||
| 6594 | 6594 | 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 | 6598 | _c.Call.Return(err) | |
| 6599 | 6599 | 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 | 6603 | _c.Call.Return(run) | |
| 6604 | 6604 | return _c | |
| 6605 | 6605 | } | |
| @@ -6922,6 +6922,86 @@ func (_c *MockHomepageHistory_GetRecent_Call) RunAndReturn(run func(ctx context. | |||
| 6922 | 6922 | 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 | 7005 | // Search provides a mock function for the type MockHomepageHistory | |
| 6926 | 7006 | func (_mock *MockHomepageHistory) Search(ctx context.Context, input port.HistorySearchInput) (*port.HistorySearchOutput, error) { | |
| 6927 | 7007 | ret := _mock.Called(ctx, input) | |
| @@ -7017,6 +7097,74 @@ func (_m *MockHomepageFavorites) EXPECT() *MockHomepageFavorites_Expecter { | |||
| 7017 | 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 | 7168 | // AddTag provides a mock function for the type MockHomepageFavorites | |
| 7021 | 7169 | func (_mock *MockHomepageFavorites) AddTag(ctx context.Context, name string, color string) (*entity.Tag, error) { | |
| 7022 | 7170 | ret := _mock.Called(ctx, name, color) | |
| @@ -7165,6 +7313,63 @@ func (_c *MockHomepageFavorites_CreateFolder_Call) RunAndReturn(run func(ctx con | |||
| 7165 | 7313 | 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 | 7373 | // DeleteFolder provides a mock function for the type MockHomepageFavorites | |
| 7169 | 7374 | func (_mock *MockHomepageFavorites) DeleteFolder(ctx context.Context, id entity.FolderID) error { | |
| 7170 | 7375 | ret := _mock.Called(ctx, id) | |
| @@ -7785,6 +7990,74 @@ func (_c *MockHomepageFavorites_UntagFavorite_Call) RunAndReturn(run func(ctx co | |||
| 7785 | 7990 | 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 | 8061 | // UpdateFolder provides a mock function for the type MockHomepageFavorites | |
| 7789 | 8062 | func (_mock *MockHomepageFavorites) UpdateFolder(ctx context.Context, id entity.FolderID, name string, icon string) error { | |
| 7790 | 8063 | ret := _mock.Called(ctx, id, name, icon) | |
| @@ -6490,44 +6490,44 @@ func (_c *MockHomepageHistory_ClearAll_Call) RunAndReturn(run func(ctx context.C | |||||
| 6490 | return _c | 6490 | return _c | ||
| 6491 | } | 6491 | } | ||
| 6492 | 6492 | ||||
| 6493 | - | // ClearRange provides a mock function for the type MockHomepageHistory | 6493 | + | // 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 error | 6501 | 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 r0 | 6507 | 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.Call | 6512 | *mock.Call | ||
| 6513 | } | 6513 | } | ||
| 6514 | 6514 | ||||
| 6515 | - | // ClearRange is a helper method to define mock.On call | 6515 | + | // ClearOlderThan is a helper method to define mock.On call |
| 6516 | // - ctx context.Context | 6516 | // - ctx context.Context | ||
| 6517 | - | // - rangeID string | 6517 | + | // - 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.Context | 6524 | 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 string | 6528 | + | 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 _c | 6537 | 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 _c | 6542 | 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 _c | 6547 | return _c | ||
| 6548 | } | 6548 | } | ||
| 6549 | 6549 | ||||
| 6550 | - | // ClearOlderThan provides a mock function for the type MockHomepageHistory | 6550 | + | // 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 error | 6558 | 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 r0 | 6564 | 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.Call | 6569 | *mock.Call | ||
| 6570 | } | 6570 | } | ||
| 6571 | 6571 | ||||
| 6572 | - | // ClearOlderThan is a helper method to define mock.On call | 6572 | + | // ClearRange is a helper method to define mock.On call |
| 6573 | // - ctx context.Context | 6573 | // - ctx context.Context | ||
| 6574 | - | // - before time.Time | 6574 | + | // - 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.Context | 6581 | 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.Time | 6585 | + | 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 _c | 6594 | 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 _c | 6599 | 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 _c | 6604 | return _c | ||
| 6605 | } | 6605 | } | ||
| @@ -6922,6 +6922,86 @@ func (_c *MockHomepageHistory_GetRecent_Call) RunAndReturn(run func(ctx context. | |||||
| 6922 | return _c | 6922 | 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 MockHomepageHistory | 7005 | // 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 MockHomepageFavorites | 7168 | // 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 _c | 7313 | 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 MockHomepageFavorites | 7373 | // 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 _c | 7990 | 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 MockHomepageFavorites | 8061 | // 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 ( | |||
| 9 | 9 | // SystemviewHistoryService exposes history operations for the systemviews history route. | |
| 10 | 10 | type SystemviewHistoryService interface { | |
| 11 | 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 | 13 | Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error) | |
| 13 | 14 | DeleteEntry(ctx context.Context, id int64) error | |
| 14 | 15 | 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) error | 14 | DeleteEntry(ctx context.Context, id int64) error | ||
| 14 | DeleteRange(ctx context.Context, rangeID string) error | 15 | 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) { | |||
| 34 | 34 | require.Equal(t, []string{"default"}, reader.calls) | |
| 35 | 35 | } | |
| 36 | 36 | ||
| 37 | + | // Handwritten fake to capture stateful config reads for assertions. | |
| 37 | 38 | type stubSystemviewConfigReader struct { | |
| 38 | 39 | current port.SystemviewConfigPayload | |
| 39 | 40 | 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.SystemviewConfigPayload | 39 | current port.SystemviewConfigPayload | ||
| 39 | defaultPayload port.SystemviewConfigPayload | 40 | defaultPayload port.SystemviewConfigPayload | ||
M internal/application/usecase/save_webui_config_test.go +2 -0
| @@ -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 | 99 | type fakeWebUIConfigSaver struct{} | |
| 99 | 100 | ||
| 100 | 101 | func (fakeWebUIConfigSaver) SaveWebUIConfig(context.Context, port.WebUIConfig) error { | |
| 101 | 102 | return nil | |
| 102 | 103 | } | |
| 103 | 104 | ||
| 105 | + | // Handwritten fake to capture the saved config state for assertions. | |
| 104 | 106 | type capturingWebUIConfigSaver struct { | |
| 105 | 107 | saved port.WebUIConfig | |
| 106 | 108 | } | |
| @@ -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 nil | 102 | 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.WebUIConfig | 107 | saved port.WebUIConfig | ||
| 106 | } | 108 | } | ||
M internal/application/usecase/search_history.go +23 -0
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | 10 | historydomain "github.com/bnema/dumber/internal/domain/history" | |
| 11 | 11 | "github.com/bnema/dumber/internal/domain/repository" | |
| 12 | + | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| 12 | 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 | 76 | 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 | 96 | // GetRecentSince retrieves history entries visited within the last N days. | |
| 79 | 97 | // If days is 0, returns all history entries. | |
| 80 | 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 | 215 | // DeleteByDomain removes all history entries for a domain. | |
| 198 | 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 | 222 | log := logging.FromContext(ctx) | |
| 200 | 223 | log.Debug().Str("domain", domain).Msg("deleting history by domain") | |
| 201 | 224 | ||
| @@ -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, nil | 76 | 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 { | |||
| 21 | 21 | // GetRecent retrieves recent history entries with pagination. | |
| 22 | 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 | 27 | // GetRecentSince retrieves history entries visited within the last N days. | |
| 25 | 28 | // days must be > 0. | |
| 26 | 29 | 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 | |||
| 2661 | 2661 | 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 | 2744 | // GetRecentSince provides a mock function for the type MockHistoryRepository | |
| 2665 | 2745 | func (_mock *MockHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | |
| 2666 | 2746 | ret := _mock.Called(ctx, days) | |
| @@ -2661,6 +2661,86 @@ func (_c *MockHistoryRepository_GetRecent_Call) RunAndReturn(run func(ctx contex | |||||
| 2661 | return _c | 2661 | 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 MockHistoryRepository | 2744 | // 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) { | |||
| 18 | 18 | in: "dumb://history", | |
| 19 | 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 | 22 | name: "error page root", | |
| 28 | 23 | in: "dumb://error", | |
| @@ -154,11 +149,6 @@ func TestToConceptualInternalURL(t *testing.T) { | |||
| 154 | 149 | in: "https://dumber.invalid/history", | |
| 155 | 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 | 153 | name: "error page root", | |
| 164 | 154 | 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 { | |||
| 500 | 500 | } | |
| 501 | 501 | ||
| 502 | 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 | 504 | if err != nil { | |
| 505 | 505 | h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found") | |
| 506 | 506 | return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found") | |
| @@ -513,7 +513,17 @@ func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler { | |||
| 513 | 513 | Int("size", len(data)). | |
| 514 | 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 | 529 | // 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 | 593 | if !strings.Contains(path, "/") && strings.Contains(path, ".") { | |
| 584 | - | return assetDirForPageHost(historyPath), path, true | |
| 594 | + | return "systemviews", path, true | |
| 585 | 595 | } | |
| 586 | 596 | ||
| 587 | 597 | 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 URL | 529 | // 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, true | 594 | + | return "systemviews", path, true |
| 585 | } | 595 | } | ||
| 586 | 596 | ||||
| 587 | return "", "", false | 597 | return "", "", false | ||
M internal/infrastructure/config/defaults_test.go +2 -1
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | 6 | "github.com/stretchr/testify/assert" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| 9 | 10 | func TestDefaultConfig_CoreDefaults(t *testing.T) { | |
| @@ -38,6 +39,6 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) { | |||
| 38 | 39 | func requireActionBinding(t *testing.T, actions map[string]ActionBinding, action string, keys []string) { | |
| 39 | 40 | t.Helper() | |
| 40 | 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 | 43 | assert.Equal(t, keys, binding.Keys) | |
| 43 | 44 | } | |
| @@ -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 | |||
| 31 | 31 | } | |
| 32 | 32 | ||
| 33 | 33 | var hw *port.HardwareInfo | |
| 34 | - | if r != nil && r.hwSurveyor != nil { | |
| 34 | + | if r.hwSurveyor != nil { | |
| 35 | 35 | survey := r.hwSurveyor.Survey(ctx) | |
| 36 | 36 | hw = &survey | |
| 37 | 37 | } | |
| @@ -31,7 +31,7 @@ func (r *systemviewConfigReader) build(ctx context.Context, cfg *Config) (port.S | |||||
| 31 | } | 31 | } | ||
| 32 | 32 | ||||
| 33 | var hw *port.HardwareInfo | 33 | 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 = &survey | 36 | hw = &survey | ||
| 37 | } | 37 | } | ||
M internal/infrastructure/handlers/homepage/history.go +33 -0
| @@ -26,6 +26,13 @@ type timelineRequest struct { | |||
| 26 | 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 | 36 | // HandleTimeline handles history_timeline messages. | |
| 30 | 37 | func (h *HistoryHandlers) HandleTimeline() port.WebUIMessageHandler { | |
| 31 | 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 | 87 | // searchRequest is the payload for history_search_fts messages. | |
| 55 | 88 | type searchRequest struct { | |
| 56 | 89 | 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 | |||
| 29 | 29 | // History handlers | |
| 30 | 30 | historyHandlers := NewHistoryHandlers(cfg.HistoryUC) | |
| 31 | 31 | handlers["history_timeline"] = historyHandlers.HandleTimeline() | |
| 32 | + | handlers["history_timeline_by_domain"] = historyHandlers.HandleTimelineByDomain() | |
| 32 | 33 | handlers["history_search_fts"] = historyHandlers.HandleSearchFTS() | |
| 33 | 34 | handlers["history_delete_entry"] = historyHandlers.HandleDeleteEntry() | |
| 34 | 35 | handlers["history_delete_range"] = historyHandlers.HandleDeleteRange() | |
| @@ -29,6 +29,7 @@ func RegisterHandlers(ctx context.Context, router port.WebUIHandlerRouter, cfg C | |||||
| 29 | // History handlers | 29 | // 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 ( | |||
| 14 | 14 | // ==================== Favorite Repository ==================== | |
| 15 | 15 | ||
| 16 | 16 | type favoriteRepo struct { | |
| 17 | + | db *sql.DB | |
| 17 | 18 | queries *sqlc.Queries | |
| 18 | 19 | } | |
| 19 | 20 | ||
| 20 | 21 | // NewFavoriteRepository creates a new SQLite-backed favorite repository. | |
| 21 | 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 | 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 | 35 | 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) | |
| 49 | 37 | } | |
| 50 | 38 | ||
| 51 | 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 | 49 | 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 | 84 | func int64Value(value *int) int64 { | |
| 65 | 85 | if value == nil { | |
| 66 | 86 | 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.Queries | 18 | 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 nil | 49 | 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 0 | 86 | 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 | |||
| 488 | 488 | if err != nil { | |
| 489 | 489 | return nil, err | |
| 490 | 490 | } | |
| 491 | + | return historyEntriesFromRows(rows), nil | |
| 492 | + | } | |
| 491 | 493 | ||
| 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 | |
| 495 | 498 | } | |
| 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 | |
| 497 | 508 | } | |
| 498 | 509 | ||
| 499 | 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 | 606 | 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 != ""}) | |
| 602 | 609 | } | |
| 603 | 610 | ||
| 604 | 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 | 701 | 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 | 712 | func historyFromRow(row sqlc.History) *entity.HistoryEntry { | |
| 698 | 713 | return &entity.HistoryEntry{ | |
| 699 | 714 | 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, err | 489 | 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, 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 | |||
| 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, nil | 701 | 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 | |||
| 149 | 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 | 159 | func (r *LazyHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | |
| 153 | 160 | if err := r.init(ctx); err != nil { | |
| 154 | 161 | 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, err | 161 | return nil, err | ||
M internal/infrastructure/persistence/sqlite/queries/history.sql +22 -1
| @@ -1,6 +1,19 @@ | |||
| 1 | 1 | -- name: GetRecentHistory :many | |
| 2 | 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 | 17 | -- name: GetHistoryByURL :one | |
| 5 | 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 | 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 | 67 | -- 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 | 78 | -- name: SearchHistoryFTSUrl :many | |
| 58 | 79 | 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 :many | 1 | -- 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 :one | 17 | -- 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 :exec | 67 | -- 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 :many | 78 | -- name: SearchHistoryFTSUrl :many | ||
| 58 | SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at | 79 | 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 { | |||
| 35 | 35 | } | |
| 36 | 36 | ||
| 37 | 37 | 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 { | |
| 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) | |
| 55 | 51 | return err | |
| 56 | 52 | } | |
| 57 | 53 | ||
| @@ -362,6 +358,57 @@ func (q *Queries) GetRecentHistory(ctx context.Context, arg GetRecentHistoryPara | |||
| 362 | 358 | 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 | 412 | const GetRecentHistorySince = `-- name: GetRecentHistorySince :many | |
| 366 | 413 | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history | |
| 367 | 414 | 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 :exec | 37 | 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 err | 51 | return err | ||
| 56 | } | 52 | } | ||
| 57 | 53 | ||||
| @@ -362,6 +358,57 @@ func (q *Queries) GetRecentHistory(ctx context.Context, arg GetRecentHistoryPara | |||||
| 362 | return items, nil | 358 | 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 :many | 412 | const GetRecentHistorySince = `-- name: GetRecentHistorySince :many | ||
| 366 | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history | 413 | 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 { | |||
| 21 | 21 | DeleteExitedSessionsBefore(ctx context.Context, endedAt sql.NullTime) (int64, error) | |
| 22 | 22 | DeleteFavorite(ctx context.Context, id int64) error | |
| 23 | 23 | DeleteFolder(ctx context.Context, id int64) error | |
| 24 | - | DeleteHistoryByDomain(ctx context.Context, arg DeleteHistoryByDomainParams) error | |
| 24 | + | DeleteHistoryByDomain(ctx context.Context, domain sql.NullString) error | |
| 25 | 25 | DeleteHistoryByID(ctx context.Context, id int64) error | |
| 26 | 26 | DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error | |
| 27 | 27 | // Deletes exited browser sessions beyond the keep limit, keeping the most recent ones. | |
| @@ -54,6 +54,7 @@ type Querier interface { | |||
| 54 | 54 | GetMostVisited(ctx context.Context, datetime interface{}) ([]History, error) | |
| 55 | 55 | GetPermission(ctx context.Context, arg GetPermissionParams) (Permission, error) | |
| 56 | 56 | GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error) | |
| 57 | + | GetRecentHistoryByDomain(ctx context.Context, arg GetRecentHistoryByDomainParams) ([]History, error) | |
| 57 | 58 | GetRecentHistorySince(ctx context.Context, datetime interface{}) ([]History, error) | |
| 58 | 59 | GetRecentSessions(ctx context.Context, limit int64) ([]Session, error) | |
| 59 | 60 | 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) error | 22 | DeleteFavorite(ctx context.Context, id int64) error | ||
| 23 | DeleteFolder(ctx context.Context, id int64) error | 23 | DeleteFolder(ctx context.Context, id int64) error | ||
| 24 | - | DeleteHistoryByDomain(ctx context.Context, arg DeleteHistoryByDomainParams) error | 24 | + | DeleteHistoryByDomain(ctx context.Context, domain sql.NullString) error |
| 25 | DeleteHistoryByID(ctx context.Context, id int64) error | 25 | DeleteHistoryByID(ctx context.Context, id int64) error | ||
| 26 | DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error | 26 | 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 ( | |||
| 9 | 9 | "errors" | |
| 10 | 10 | "fmt" | |
| 11 | 11 | "strings" | |
| 12 | + | "sync" | |
| 12 | 13 | "syscall/js" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| @@ -23,7 +24,7 @@ func NewBrowserClient() *Client { | |||
| 23 | 24 | ||
| 24 | 25 | func callbackPlanForMessage(msgType string) (callbackPlan, bool) { | |
| 25 | 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 | 28 | "history_domain_stats", "history_delete_domain", "favorite_list", "favorite_create", "favorite_update", "favorite_delete", "folder_list", "tag_list", | |
| 28 | 29 | "favorite_set_shortcut", "favorite_set_folder", "folder_create", "folder_update", "folder_delete", | |
| 29 | 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 | 124 | err error | |
| 124 | 125 | } | |
| 125 | 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 | 142 | successFn := js.FuncOf(func(_ js.Value, args []js.Value) any { | |
| 128 | 143 | payload, err := stringifyArgs(args) | |
| 129 | 144 | if err != nil { | |
| 130 | - | resultCh <- result{err: err} | |
| 145 | + | publish(result{err: err}) | |
| 131 | 146 | return nil | |
| 132 | 147 | } | |
| 133 | 148 | normalized, err := normalizeNativeSuccessResponse(req.requestID, payload) | |
| 134 | - | resultCh <- result{body: normalized, err: err} | |
| 149 | + | publish(result{body: normalized, err: err}) | |
| 135 | 150 | return nil | |
| 136 | 151 | }) | |
| 137 | 152 | errorFn := js.FuncOf(func(_ js.Value, args []js.Value) any { | |
| 138 | 153 | payload, err := stringifyArgs(args) | |
| 139 | 154 | if err != nil { | |
| 140 | - | resultCh <- result{err: err} | |
| 155 | + | publish(result{err: err}) | |
| 141 | 156 | return nil | |
| 142 | 157 | } | |
| 143 | 158 | normalized, err := normalizeNativeErrorResponse(req.requestID, payload) | |
| 144 | - | resultCh <- result{body: normalized, err: err} | |
| 159 | + | publish(result{body: normalized, err: err}) | |
| 145 | 160 | return nil | |
| 146 | 161 | }) | |
| 147 | - | defer successFn.Release() | |
| 148 | - | defer errorFn.Release() | |
| 149 | 162 | ||
| 150 | 163 | previousSuccess := window.Get(plan.success) | |
| 151 | 164 | previousError := window.Get(plan.failure) | |
| 152 | 165 | window.Set(plan.success, successFn) | |
| 153 | 166 | 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 | + | }() | |
| 156 | 176 | ||
| 157 | 177 | handler.Call("postMessage", js.Global().Get("JSON").Call("parse", string(req.body))) | |
| 158 | 178 | ||
| @@ -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 error | 124 | 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 nil | 146 | 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 nil | 150 | 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 nil | 156 | 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 nil | 160 | 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 | |||
| 64 | 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 | 76 | func (c *Client) Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error) { | |
| 68 | 77 | return request[[]*entity.HistoryEntry](c, ctx, "history_search_fts", struct { | |
| 69 | 78 | RequestID string `json:"requestId"` | |
| @@ -346,7 +355,7 @@ func (c *Client) transport() Transport { | |||
| 346 | 355 | if c.native != nil && c.native.Available() { | |
| 347 | 356 | return c.native | |
| 348 | 357 | } | |
| 349 | - | if c.fetch != nil { | |
| 358 | + | if c.fetch != nil && c.fetch.Available() { | |
| 350 | 359 | return c.fetch | |
| 351 | 360 | } | |
| 352 | 361 | 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.native | 356 | return c.native | ||
| 348 | } | 357 | } | ||
| 349 | - | if c.fetch != nil { | 358 | + | if c.fetch != nil && c.fetch.Available() { |
| 350 | return c.fetch | 359 | return c.fetch | ||
| 351 | } | 360 | } | ||
| 352 | return nil | 361 | return nil | ||
M internal/infrastructure/webkit/scheme_handler.go +21 -3
| @@ -44,6 +44,7 @@ type SchemeResponse struct { | |||
| 44 | 44 | Data []byte | |
| 45 | 45 | ContentType string | |
| 46 | 46 | StatusCode int | |
| 47 | + | Headers map[string]string | |
| 47 | 48 | } | |
| 48 | 49 | ||
| 49 | 50 | // PageHandler generates content for a specific page path. | |
| @@ -299,9 +300,8 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse { | |||
| 299 | 300 | assetDir = h.assetDir | |
| 300 | 301 | } | |
| 301 | 302 | ||
| 302 | - | // Read the asset from embedded FS | |
| 303 | 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 | 305 | if err != nil { | |
| 306 | 306 | h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found") | |
| 307 | 307 | return nil | |
| @@ -318,9 +318,20 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse { | |||
| 318 | 318 | Data: data, | |
| 319 | 319 | ContentType: contentType, | |
| 320 | 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 | 335 | func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { | |
| 325 | 336 | if u == nil { | |
| 326 | 337 | return "", "", false | |
| @@ -407,7 +418,14 @@ func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response | |||
| 407 | 418 | ||
| 408 | 419 | // WebKit can treat custom schemes as CORS-relevant even for same-origin fetch(). | |
| 409 | 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 | 429 | hdrs := soup.NewMessageHeaders(soup.MessageHeadersResponseValue) | |
| 412 | 430 | for name, value := range headers { | |
| 413 | 431 | hdrs.Append(name, value) | |
| @@ -44,6 +44,7 @@ type SchemeResponse struct { | |||||
| 44 | Data []byte | 44 | Data []byte | ||
| 45 | ContentType string | 45 | ContentType string | ||
| 46 | StatusCode int | 46 | 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.assetDir | 300 | 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 nil | 307 | 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 "", "", false | 337 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | + | "fmt" | |
| 6 | 7 | "strings" | |
| 7 | 8 | "sync" | |
| 8 | 9 | ||
| @@ -44,6 +45,8 @@ type App struct { | |||
| 44 | 45 | configError string | |
| 45 | 46 | renderedHTML string | |
| 46 | 47 | actionQueue chan DOMAction | |
| 48 | + | actionCtx context.Context | |
| 49 | + | actionCancel context.CancelFunc | |
| 47 | 50 | } | |
| 48 | 51 | ||
| 49 | 52 | const historyTimelineLimit = 25 | |
| @@ -53,12 +56,19 @@ func NewApp(deps Dependencies) *App { | |||
| 53 | 56 | } | |
| 54 | 57 | ||
| 55 | 58 | func (a *App) Run() error { | |
| 59 | + | return a.RunWithContext(context.Background()) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func (a *App) RunWithContext(ctx context.Context) error { | |
| 56 | 63 | if a == nil { | |
| 57 | 64 | return errors.New("app is nil") | |
| 58 | 65 | } | |
| 66 | + | if ctx == nil { | |
| 67 | + | ctx = context.Background() | |
| 68 | + | } | |
| 59 | 69 | ||
| 60 | 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 | 72 | return err | |
| 63 | 73 | } | |
| 64 | 74 | if a.deps.DOM == nil { | |
| @@ -69,21 +79,43 @@ func (a *App) Run() error { | |||
| 69 | 79 | return err | |
| 70 | 80 | } | |
| 71 | 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 | 84 | return nil | |
| 75 | 85 | } | |
| 76 | 86 | ||
| 77 | 87 | func (a *App) bindDOMActions(ctx context.Context, binder DOMActionBinder) error { | |
| 78 | 88 | if a.actionQueue == nil { | |
| 89 | + | workerCtx, cancel := context.WithCancel(ctx) | |
| 79 | 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 | 95 | 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 | + | } | |
| 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 | 119 | func (a *App) runActionWorker(ctx context.Context) { | |
| 88 | 120 | for { | |
| 89 | 121 | select { | |
| @@ -93,11 +125,38 @@ func (a *App) runActionWorker(ctx context.Context) { | |||
| 93 | 125 | if !ok { | |
| 94 | 126 | 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 | 160 | func (a *App) LoadInitial(ctx context.Context) error { | |
| 102 | 161 | if a == nil { | |
| 103 | 162 | return errors.New("app is nil") | |
| @@ -182,7 +241,6 @@ func (a *App) loadHistoryRoute(ctx context.Context) error { | |||
| 182 | 241 | a.favorites = nil | |
| 183 | 242 | a.folders = nil | |
| 184 | 243 | a.tags = nil | |
| 185 | - | entries = filterHistoryEntriesByDomain(entries, a.historyDomainFilter) | |
| 186 | 244 | a.historyEntries = entries | |
| 187 | 245 | a.historyAnalytics = analytics | |
| 188 | 246 | a.historyDomainStats = domains | |
| @@ -206,8 +264,16 @@ func (a *App) loadHistoryRoute(ctx context.Context) error { | |||
| 206 | 264 | ||
| 207 | 265 | func (a *App) loadHistoryEntries(ctx context.Context) ([]*entity.HistoryEntry, error) { | |
| 208 | 266 | query := strings.TrimSpace(a.historyQuery) | |
| 267 | + | domain := strings.TrimSpace(a.historyDomainFilter) | |
| 209 | 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 | 278 | return a.deps.History.Timeline(ctx, historyTimelineLimit, a.historyOffset) | |
| 213 | 279 | } | |
| @@ -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 string | 45 | configError string | ||
| 45 | renderedHTML string | 46 | renderedHTML string | ||
| 46 | actionQueue chan DOMAction | 47 | actionQueue chan DOMAction | ||
| 48 | + | actionCtx context.Context | |||
| 49 | + | actionCancel context.CancelFunc | |||
| 47 | } | 50 | } | ||
| 48 | 51 | ||||
| 49 | const historyTimelineLimit = 25 | 52 | 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 err | 72 | 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 err | 79 | 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 nil | 84 | 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 <- action | 96 | + | 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 | return | 126 | 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 = nil | 241 | a.favorites = nil | ||
| 183 | a.folders = nil | 242 | a.folders = nil | ||
| 184 | a.tags = nil | 243 | a.tags = nil | ||
| 185 | - | entries = filterHistoryEntriesByDomain(entries, a.historyDomainFilter) | |||
| 186 | a.historyEntries = entries | 244 | a.historyEntries = entries | ||
| 187 | a.historyAnalytics = analytics | 245 | a.historyAnalytics = analytics | ||
| 188 | a.historyDomainStats = domains | 246 | 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) { | |||
| 110 | 110 | assert.Contains(t, app.renderedHTML, `data-range="hour"`) | |
| 111 | 111 | assert.Contains(t, app.renderedHTML, `data-sv-action="history.filterDomain"`) | |
| 112 | 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 | 114 | assert.Contains(t, app.renderedHTML, `>example.com</button>`) | |
| 115 | 115 | assert.Contains(t, app.renderedHTML, "Keys:") | |
| 116 | 116 | assert.Contains(t, app.renderedHTML, "Enter") | |
| @@ -147,6 +147,14 @@ func TestAppHandleHistoryActionsRefreshesDOM(t *testing.T) { | |||
| 147 | 147 | assert.Equal(t, "week", history.deletedRangeID) | |
| 148 | 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 | 158 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| 151 | 159 | Action: historyActionDeleteDomain, | |
| 152 | 160 | Data: map[string]string{"domain": "example.com"}, | |
| @@ -320,6 +328,22 @@ func TestAppHandleFavoriteActionsRefreshesDOM(t *testing.T) { | |||
| 320 | 328 | assert.Equal(t, int64(42), favorites.assignedFavoriteID) | |
| 321 | 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 | 347 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| 324 | 348 | Action: favoriteActionDelete, | |
| 325 | 349 | Data: map[string]string{"id": "42"}, | |
| @@ -591,6 +615,7 @@ func testDefaultConfigPayload() port.SystemviewConfigPayload { | |||
| 591 | 615 | return cfg | |
| 592 | 616 | } | |
| 593 | 617 | ||
| 618 | + | // Handwritten fake to capture DOM mounts for stateful render assertions. | |
| 594 | 619 | type fakeDOM struct { | |
| 595 | 620 | mounted bool | |
| 596 | 621 | html string | |
| @@ -602,12 +627,15 @@ func (d *fakeDOM) Mount(html string) error { | |||
| 602 | 627 | return nil | |
| 603 | 628 | } | |
| 604 | 629 | ||
| 630 | + | // Handwritten fake to capture history state for stateful render assertions. | |
| 605 | 631 | 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 | |
| 611 | 639 | ||
| 612 | 640 | searchCalled bool | |
| 613 | 641 | query string | |
| @@ -627,6 +655,14 @@ func (s *fakeHistoryService) Timeline(_ context.Context, limit, offset int) ([]* | |||
| 627 | 655 | 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 | 666 | func (s *fakeHistoryService) Search(_ context.Context, query string, limit int) ([]*entity.HistoryEntry, error) { | |
| 631 | 667 | s.searchCalled = true | |
| 632 | 668 | s.query = query | |
| @@ -660,6 +696,7 @@ func (s *fakeHistoryService) DeleteDomain(_ context.Context, domain string) erro | |||
| 660 | 696 | return nil | |
| 661 | 697 | } | |
| 662 | 698 | ||
| 699 | + | // Handwritten fake to capture favorites state for stateful render assertions. | |
| 663 | 700 | type fakeFavoritesService struct { | |
| 664 | 701 | calledList bool | |
| 665 | 702 | calledFolders bool | |
| @@ -759,6 +796,7 @@ func (s *fakeFavoritesService) RemoveTag(_ context.Context, favoriteID, tagID in | |||
| 759 | 796 | return nil | |
| 760 | 797 | } | |
| 761 | 798 | ||
| 799 | + | // Handwritten fake to capture config state for stateful render assertions. | |
| 762 | 800 | type fakeConfigService struct { | |
| 763 | 801 | calledCurrent bool | |
| 764 | 802 | 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 cfg | 615 | 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 bool | 620 | mounted bool | ||
| 596 | html string | 621 | html string | ||
| @@ -602,12 +627,15 @@ func (d *fakeDOM) Mount(html string) error { | |||||
| 602 | return nil | 627 | 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 bool | 632 | + | called bool |
| 607 | - | limit int | 633 | + | limit int |
| 608 | - | offset int | 634 | + | offset int |
| 609 | - | entries []*entity.HistoryEntry | 635 | + | entries []*entity.HistoryEntry |
| 610 | - | err error | 636 | + | err error |
| 637 | + | domainCalled bool | |||
| 638 | + | domain string | |||
| 611 | 639 | ||||
| 612 | searchCalled bool | 640 | searchCalled bool | ||
| 613 | query string | 641 | query string | ||
| @@ -627,6 +655,14 @@ func (s *fakeHistoryService) Timeline(_ context.Context, limit, offset int) ([]* | |||||
| 627 | return s.entries, s.err | 655 | 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 = true | 667 | s.searchCalled = true | ||
| 632 | s.query = query | 668 | s.query = query | ||
| @@ -660,6 +696,7 @@ func (s *fakeHistoryService) DeleteDomain(_ context.Context, domain string) erro | |||||
| 660 | return nil | 696 | 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 bool | 701 | calledList bool | ||
| 665 | calledFolders bool | 702 | calledFolders bool | ||
| @@ -759,6 +796,7 @@ func (s *fakeFavoritesService) RemoveTag(_ context.Context, favoriteID, tagID in | |||||
| 759 | return nil | 796 | 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 bool | 801 | calledCurrent bool | ||
| 764 | calledDefault bool | 802 | calledDefault bool | ||
M internal/ui/systemviews/config.templ +6 -5
| @@ -71,11 +71,12 @@ templ SearchSettings(config port.SystemviewConfigPayload) { | |||
| 71 | 71 | <label><span>Description</span><input name="description" type="text" placeholder="DuckDuckGo"/></label> | |
| 72 | 72 | <button class="sv-button sv-button-secondary" type="submit">Add shortcut</button> | |
| 73 | 73 | </form> | |
| 74 | - | if len(searchShortcutRows(config.SearchShortcuts)) == 0 { | |
| 74 | + | {{ shortcuts := searchShortcutRows(config.SearchShortcuts) }} | |
| 75 | + | if len(shortcuts) == 0 { | |
| 75 | 76 | @EmptyState("No search shortcuts configured") | |
| 76 | 77 | } else { | |
| 77 | 78 | <ul class="sv-list"> | |
| 78 | - | for _, shortcut := range searchShortcutRows(config.SearchShortcuts) { | |
| 79 | + | for _, shortcut := range shortcuts { | |
| 79 | 80 | @SearchShortcutItem(shortcut) | |
| 80 | 81 | } | |
| 81 | 82 | </ul> | |
| @@ -126,14 +127,14 @@ templ PerformanceSettings(config port.SystemviewConfigPayload) { | |||
| 126 | 127 | ||
| 127 | 128 | templ ConfigKeybindings(keybindings port.KeybindingsConfig) { | |
| 128 | 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 | 130 | if groups == nil { | |
| 133 | 131 | @EmptyState("Keybindings unavailable") | |
| 134 | 132 | } else if len(groups) == 0 { | |
| 135 | 133 | @EmptyState("No keybindings configured") | |
| 136 | 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 | 138 | for _, group := range groups { | |
| 138 | 139 | @ConfigKeybindingGroup(group) | |
| 139 | 140 | } | |
| @@ -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) | |||
| 124 | 124 | return err | |
| 125 | 125 | } | |
| 126 | 126 | key, shortcut := searchShortcutFromForm(data, "key") | |
| 127 | + | if key == "" { | |
| 128 | + | return fmt.Errorf("search shortcut key is required") | |
| 129 | + | } | |
| 127 | 130 | cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts) | |
| 128 | 131 | if _, exists := cfg.SearchShortcuts[key]; exists { | |
| 129 | 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 | 149 | return fmt.Errorf("search shortcut key is required") | |
| 147 | 150 | } | |
| 148 | 151 | newKey, shortcut := searchShortcutFromForm(data, "new_key") | |
| 152 | + | if newKey == "" { | |
| 153 | + | return fmt.Errorf("new search shortcut key is required") | |
| 154 | + | } | |
| 149 | 155 | cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts) | |
| 150 | 156 | if _, exists := cfg.SearchShortcuts[oldKey]; !exists { | |
| 151 | 157 | 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 err | 124 | 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 generated file: collapsed
M internal/ui/systemviews/config_view.go +11 -5
| @@ -16,6 +16,8 @@ type configRenderData struct { | |||
| 16 | 16 | Error string | |
| 17 | 17 | } | |
| 18 | 18 | ||
| 19 | + | const maxFlattenDepth = 16 | |
| 20 | + | ||
| 19 | 21 | func configHTML(data configRenderData) string { | |
| 20 | 22 | return mustRenderComponent(ConfigView(data)) | |
| 21 | 23 | } | |
| @@ -140,15 +142,19 @@ func engineIsWebKit(engine string) bool { | |||
| 140 | 142 | ||
| 141 | 143 | func flattenValues(value any) []kvPair { | |
| 142 | 144 | rows := make([]kvPair, 0) | |
| 143 | - | flattenValue(&rows, "", reflect.ValueOf(value)) | |
| 145 | + | flattenValue(&rows, "", reflect.ValueOf(value), 0) | |
| 144 | 146 | 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 | 150 | if !value.IsValid() { | |
| 149 | 151 | appendFlatValue(rows, path, "null") | |
| 150 | 152 | return | |
| 151 | 153 | } | |
| 154 | + | if depth >= maxFlattenDepth { | |
| 155 | + | appendFlatValue(rows, path, "...") | |
| 156 | + | return | |
| 157 | + | } | |
| 152 | 158 | ||
| 153 | 159 | for value.Kind() == reflect.Interface { | |
| 154 | 160 | if value.IsNil() { | |
| @@ -182,7 +188,7 @@ func flattenValue(rows *[]kvPair, path string, value reflect.Value) { | |||
| 182 | 188 | if path != "" { | |
| 183 | 189 | nextPath = path + "." + name | |
| 184 | 190 | } | |
| 185 | - | flattenValue(rows, nextPath, value.Field(i)) | |
| 191 | + | flattenValue(rows, nextPath, value.Field(i), depth+1) | |
| 186 | 192 | } | |
| 187 | 193 | case reflect.Map: | |
| 188 | 194 | if value.Len() == 0 { | |
| @@ -201,7 +207,7 @@ func flattenValue(rows *[]kvPair, path string, value reflect.Value) { | |||
| 201 | 207 | if path != "" { | |
| 202 | 208 | nextPath = path + "." + name | |
| 203 | 209 | } | |
| 204 | - | flattenValue(rows, nextPath, value.MapIndex(key)) | |
| 210 | + | flattenValue(rows, nextPath, value.MapIndex(key), depth+1) | |
| 205 | 211 | } | |
| 206 | 212 | case reflect.Slice, reflect.Array: | |
| 207 | 213 | if value.Len() == 0 { | |
| @@ -214,7 +220,7 @@ func flattenValue(rows *[]kvPair, path string, value reflect.Value) { | |||
| 214 | 220 | if path == "" { | |
| 215 | 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 | 225 | default: | |
| 220 | 226 | appendFlatValue(rows, path, formatFlatValue(value)) | |
| @@ -16,6 +16,8 @@ type configRenderData struct { | |||||
| 16 | Error string | 16 | 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 rows | 146 | 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 | return | 152 | 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 + "." + name | 189 | 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 + "." + name | 208 | 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 ( | |||
| 12 | 12 | type browserDOM struct { | |
| 13 | 13 | document js.Value | |
| 14 | 14 | 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 | |
| 16 | 22 | } | |
| 17 | 23 | ||
| 18 | 24 | func NewDOM() DOM { | |
| @@ -41,6 +47,7 @@ func (d *browserDOM) BindActions(handler DOMActionHandler) error { | |||
| 41 | 47 | if d == nil || !d.target.Truthy() || handler == nil { | |
| 42 | 48 | return fmt.Errorf("DOM action binding target not available") | |
| 43 | 49 | } | |
| 50 | + | d.Release() | |
| 44 | 51 | ||
| 45 | 52 | clickHandler := js.FuncOf(func(_ js.Value, args []js.Value) any { | |
| 46 | 53 | event := firstJSArg(args) | |
| @@ -62,8 +69,7 @@ func (d *browserDOM) BindActions(handler DOMActionHandler) error { | |||
| 62 | 69 | handler(DOMAction{Action: action, Data: collectActionData(element)}) | |
| 63 | 70 | return nil | |
| 64 | 71 | }) | |
| 65 | - | d.target.Call("addEventListener", "click", clickHandler) | |
| 66 | - | d.bindings = append(d.bindings, clickHandler) | |
| 72 | + | d.addEventBinding(d.target, "click", clickHandler) | |
| 67 | 73 | ||
| 68 | 74 | submitHandler := js.FuncOf(func(_ js.Value, args []js.Value) any { | |
| 69 | 75 | event := firstJSArg(args) | |
| @@ -86,19 +92,39 @@ func (d *browserDOM) BindActions(handler DOMActionHandler) error { | |||
| 86 | 92 | handler(DOMAction{Action: action, Data: data}) | |
| 87 | 93 | return nil | |
| 88 | 94 | }) | |
| 89 | - | d.target.Call("addEventListener", "submit", submitHandler) | |
| 90 | - | d.bindings = append(d.bindings, submitHandler) | |
| 95 | + | d.addEventBinding(d.target, "submit", submitHandler) | |
| 91 | 96 | ||
| 92 | 97 | keydownHandler := js.FuncOf(func(_ js.Value, args []js.Value) any { | |
| 93 | 98 | d.handleKeyboard(firstJSArg(args)) | |
| 94 | 99 | return nil | |
| 95 | 100 | }) | |
| 96 | - | d.document.Call("addEventListener", "keydown", keydownHandler) | |
| 97 | - | d.bindings = append(d.bindings, keydownHandler) | |
| 101 | + | d.addEventBinding(d.document, "keydown", keydownHandler) | |
| 98 | 102 | ||
| 99 | 103 | 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 | 128 | func firstJSArg(args []js.Value) js.Value { | |
| 103 | 129 | if len(args) == 0 { | |
| 104 | 130 | return js.Value{} | |
| @@ -278,63 +304,28 @@ func isEditableTarget(target js.Value) bool { | |||
| 278 | 304 | } | |
| 279 | 305 | ||
| 280 | 306 | 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) | |
| 305 | 308 | } | |
| 306 | 309 | ||
| 307 | 310 | 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") | |
| 320 | 312 | } | |
| 321 | 313 | ||
| 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) | |
| 329 | 316 | } | |
| 330 | 317 | ||
| 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) | |
| 333 | 324 | length := rows.Get("length").Int() | |
| 334 | 325 | if length == 0 { | |
| 335 | 326 | return | |
| 336 | 327 | } | |
| 337 | - | idx := d.activeFavoriteIndex() | |
| 328 | + | idx := d.activeIndex(datasetKey) | |
| 338 | 329 | if idx < 0 { | |
| 339 | 330 | idx = 0 | |
| 340 | 331 | } else { | |
| @@ -351,27 +342,27 @@ func (d *browserDOM) focusFavoriteRow(delta int) { | |||
| 351 | 342 | } | |
| 352 | 343 | row := rows.Index(idx) | |
| 353 | 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 | 346 | row.Call("scrollIntoView", map[string]any{"block": "nearest"}) | |
| 356 | 347 | } | |
| 357 | 348 | ||
| 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) | |
| 360 | 351 | length := rows.Get("length").Int() | |
| 361 | 352 | if length == 0 { | |
| 362 | 353 | return js.Value{} | |
| 363 | 354 | } | |
| 364 | - | idx := d.activeFavoriteIndex() | |
| 355 | + | idx := d.activeIndex(datasetKey) | |
| 365 | 356 | if idx < 0 || idx >= length { | |
| 366 | 357 | idx = 0 | |
| 367 | - | d.target.Get("dataset").Set("svActiveFavoriteIndex", "0") | |
| 358 | + | d.target.Get("dataset").Set(datasetKey, "0") | |
| 368 | 359 | rows.Index(0).Get("classList").Call("add", "sv-focused") | |
| 369 | 360 | } | |
| 370 | 361 | return rows.Index(idx) | |
| 371 | 362 | } | |
| 372 | 363 | ||
| 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() | |
| 375 | 366 | idx, err := strconv.Atoi(value) | |
| 376 | 367 | if err != nil { | |
| 377 | 368 | return -1 | |
| @@ -12,7 +12,13 @@ import ( | |||||
| 12 | type browserDOM struct { | 12 | type browserDOM struct { | ||
| 13 | document js.Value | 13 | document js.Value | ||
| 14 | target js.Value | 14 | 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 | |||
| 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 nil | 70 | 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 nil | 93 | 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 nil | 99 | 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 nil | 103 | 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 | return | 326 | return | ||
| 336 | } | 327 | } | ||
| 337 | - | idx := d.activeFavoriteIndex() | 328 | + | idx := d.activeIndex(datasetKey) |
| 338 | if idx < 0 { | 329 | if idx < 0 { | ||
| 339 | idx = 0 | 330 | 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 = 0 | 357 | 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 -1 | 368 | 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 { | |||
| 81 | 81 | } | |
| 82 | 82 | a.favoritesNotice = "Deleted favorite" | |
| 83 | 83 | case favoriteActionFilterFolder: | |
| 84 | - | folderID, err := filterFolderID(data["folder_id"]) | |
| 84 | + | folderID, err := filterFolderID(firstActionValue(data, "folderId", "folder_id")) | |
| 85 | 85 | if err != nil { | |
| 86 | 86 | return err | |
| 87 | 87 | } | |
| @@ -89,7 +89,7 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error { | |||
| 89 | 89 | a.favoriteTagFilter = nil | |
| 90 | 90 | a.favoritesNotice = "" | |
| 91 | 91 | case favoriteActionFilterTag: | |
| 92 | - | tagID, err := parsePositiveInt64(data["tag_id"], "tag id") | |
| 92 | + | tagID, err := parsePositiveInt64(firstActionValue(data, "tagId", "tag_id"), "tag id") | |
| 93 | 93 | if err != nil { | |
| 94 | 94 | return err | |
| 95 | 95 | } | |
| @@ -191,6 +191,15 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error { | |||
| 191 | 191 | 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 | 203 | func parsePositiveInt64(raw, label string) (int64, error) { | |
| 195 | 204 | id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) | |
| 196 | 205 | 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 err | 86 | return err | ||
| 87 | } | 87 | } | ||
| @@ -89,7 +89,7 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error { | |||||
| 89 | a.favoriteTagFilter = nil | 89 | 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 err | 94 | return err | ||
| 95 | } | 95 | } | ||
| @@ -191,6 +191,15 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error { | |||||
| 191 | return nil | 191 | 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 { | |||
| 114 | 114 | a.historyNotice = "Deleted history entry" | |
| 115 | 115 | case historyActionDeleteRange: | |
| 116 | 116 | rangeID := strings.TrimSpace(data["range"]) | |
| 117 | + | if rangeID == "" { | |
| 118 | + | return fmt.Errorf("history range is required") | |
| 119 | + | } | |
| 117 | 120 | if err := a.deps.History.DeleteRange(ctx, rangeID); err != nil { | |
| 118 | 121 | return err | |
| 119 | 122 | } | |
| @@ -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 err | 121 | return err | ||
| 119 | } | 122 | } | ||
M internal/ui/systemviews/history_view.go +2 -2
| @@ -220,7 +220,7 @@ func historyDomainActionKey(domain *entity.DomainStat) string { | |||
| 220 | 220 | if domain == nil { | |
| 221 | 221 | return "" | |
| 222 | 222 | } | |
| 223 | - | return strings.TrimSpace(domain.Domain) | |
| 223 | + | return browserurl.CanonicalDomain(domain.Domain) | |
| 224 | 224 | } | |
| 225 | 225 | ||
| 226 | 226 | func historyDomainDisplayLabel(domain *entity.DomainStat) string { | |
| @@ -266,7 +266,7 @@ func historyItemMeta(entry *entity.HistoryEntry) string { | |||
| 266 | 266 | } | |
| 267 | 267 | ||
| 268 | 268 | func nonEmptyStrings(values []string) []string { | |
| 269 | - | out := values[:0] | |
| 269 | + | out := make([]string, 0, len(values)) | |
| 270 | 270 | for _, value := range values { | |
| 271 | 271 | if strings.TrimSpace(value) != "" { | |
| 272 | 272 | 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 { | |||
| 63 | 63 | } | |
| 64 | 64 | ||
| 65 | 65 | 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 == "" { | |
| 68 | 68 | return "sv-button" | |
| 69 | 69 | } | |
| 70 | - | return classes | |
| 70 | + | return "sv-button " + extra | |
| 71 | 71 | } | |
| 72 | 72 | ||
| 73 | 73 | 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 classes | 70 | + | 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) { | |||
| 56 | 56 | for _, tt := range tests { | |
| 57 | 57 | t.Run(tt.name, func(t *testing.T) { | |
| 58 | 58 | currentPrefersDarkImpl = func() bool { return tt.prefersDark } | |
| 59 | - | appearance.ColorScheme = tt.colorScheme | |
| 59 | + | ttAppearance := appearance | |
| 60 | + | ttAppearance.ColorScheme = tt.colorScheme | |
| 60 | 61 | ||
| 61 | - | got := resolveShellTheme(appearance) | |
| 62 | + | got := resolveShellTheme(ttAppearance) | |
| 62 | 63 | ||
| 63 | 64 | assert.Equal(t, tt.wantClass, got.RootClass) | |
| 64 | 65 | 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.colorScheme | 59 | + | 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") | ||