fix(systemviews): refine history lazy loading
- commit
- ff220dd440157b14b1fe4037265aa29df937be9c
- author
- brice · · 2026-04-25 15:11 UTC
- parent
- a4e770b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
47 files · +4455 -725
M assets/systemviews/systemviews.css +188 -5
| @@ -203,6 +203,43 @@ body { | |||
| 203 | 203 | color: var(--sv-muted, #9aa3b2); | |
| 204 | 204 | } | |
| 205 | 205 | ||
| 206 | + | .sv-loading-panel, | |
| 207 | + | .sv-loading-card { | |
| 208 | + | display: inline-flex; | |
| 209 | + | align-items: center; | |
| 210 | + | gap: 0.55rem; | |
| 211 | + | color: var(--sv-muted, #9aa3b2); | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | .sv-loading-panel { | |
| 215 | + | min-height: 4rem; | |
| 216 | + | border: 1px solid var(--sv-border, #2a313d); | |
| 217 | + | border-radius: var(--sv-radius-card, 0.5rem); | |
| 218 | + | padding: 0.85rem; | |
| 219 | + | background: var(--sv-surface-variant, #1d2129); | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | .sv-spinner { | |
| 223 | + | width: 1rem; | |
| 224 | + | height: 1rem; | |
| 225 | + | border: 2px solid color-mix(in srgb, var(--sv-muted, #9aa3b2) 35%, transparent); | |
| 226 | + | border-top-color: var(--sv-accent, #8ab4ff); | |
| 227 | + | border-radius: 999px; | |
| 228 | + | animation: sv-spin 0.75s linear infinite; | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | .sv-history-load-more { | |
| 232 | + | display: flex; | |
| 233 | + | justify-content: center; | |
| 234 | + | padding: 1rem 0 0.25rem; | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | @keyframes sv-spin { | |
| 238 | + | to { | |
| 239 | + | transform: rotate(360deg); | |
| 240 | + | } | |
| 241 | + | } | |
| 242 | + | ||
| 206 | 243 | .sv-stat-grid { | |
| 207 | 244 | display: grid; | |
| 208 | 245 | grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); | |
| @@ -240,14 +277,23 @@ body { | |||
| 240 | 277 | } | |
| 241 | 278 | ||
| 242 | 279 | .sv-domain-chip { | |
| 243 | - | display: inline-grid; | |
| 244 | - | gap: 0.15rem; | |
| 245 | - | padding: 0.45rem 0.65rem; | |
| 280 | + | display: grid; | |
| 281 | + | grid-template-columns: minmax(0, 1fr) auto; | |
| 282 | + | gap: 0.5rem; | |
| 283 | + | align-items: start; | |
| 284 | + | min-width: 10.5rem; | |
| 285 | + | padding: 0.55rem 0.65rem; | |
| 246 | 286 | border: 1px solid var(--sv-border, #2a313d); | |
| 247 | 287 | border-radius: var(--sv-radius-chip, 0.45rem); | |
| 248 | 288 | background: var(--sv-surface-variant, #1d2129); | |
| 249 | 289 | } | |
| 250 | 290 | ||
| 291 | + | .sv-domain-chip-main { | |
| 292 | + | display: grid; | |
| 293 | + | gap: 0.15rem; | |
| 294 | + | min-width: 0; | |
| 295 | + | } | |
| 296 | + | ||
| 251 | 297 | .sv-domain-meta, | |
| 252 | 298 | .sv-history-meta, | |
| 253 | 299 | .sv-history-url { | |
| @@ -274,6 +320,46 @@ body { | |||
| 274 | 320 | align-items: start; | |
| 275 | 321 | } | |
| 276 | 322 | ||
| 323 | + | .sv-history-content { | |
| 324 | + | display: grid; | |
| 325 | + | grid-template-columns: 24px minmax(0, 1fr); | |
| 326 | + | gap: 0.75rem; | |
| 327 | + | align-items: start; | |
| 328 | + | min-width: 0; | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | .sv-history-favicon { | |
| 332 | + | position: relative; | |
| 333 | + | display: inline-grid; | |
| 334 | + | place-items: center; | |
| 335 | + | width: 24px; | |
| 336 | + | height: 24px; | |
| 337 | + | overflow: hidden; | |
| 338 | + | color: var(--sv-muted, #9aa3b2); | |
| 339 | + | font-size: 0.75rem; | |
| 340 | + | font-weight: 800; | |
| 341 | + | line-height: 1; | |
| 342 | + | text-transform: uppercase; | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | .sv-history-favicon img { | |
| 346 | + | position: absolute; | |
| 347 | + | inset: 0; | |
| 348 | + | z-index: 1; | |
| 349 | + | width: 24px; | |
| 350 | + | height: 24px; | |
| 351 | + | object-fit: contain; | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | .sv-history-favicon-fallback { | |
| 355 | + | position: relative; | |
| 356 | + | z-index: 0; | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | .sv-history-favicon-has-image .sv-history-favicon-fallback { | |
| 360 | + | visibility: hidden; | |
| 361 | + | } | |
| 362 | + | ||
| 277 | 363 | .sv-history-main { | |
| 278 | 364 | min-width: 0; | |
| 279 | 365 | } | |
| @@ -284,7 +370,17 @@ body { | |||
| 284 | 370 | } | |
| 285 | 371 | ||
| 286 | 372 | .sv-history-url { | |
| 373 | + | display: -webkit-box; | |
| 374 | + | max-width: 100%; | |
| 375 | + | overflow: hidden; | |
| 287 | 376 | overflow-wrap: anywhere; | |
| 377 | + | -webkit-box-orient: vertical; | |
| 378 | + | -webkit-line-clamp: 2; | |
| 379 | + | line-clamp: 2; | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | .sv-app.sv-dark .sv-history-meta { | |
| 383 | + | color: var(--sv-text, #e8ebf0); | |
| 288 | 384 | } | |
| 289 | 385 | ||
| 290 | 386 | .sv-error { | |
| @@ -397,6 +493,7 @@ body { | |||
| 397 | 493 | display: inline-flex; | |
| 398 | 494 | align-items: center; | |
| 399 | 495 | justify-content: center; | |
| 496 | + | gap: 0.4rem; | |
| 400 | 497 | min-height: 2rem; | |
| 401 | 498 | border: 1px solid var(--sv-accent, #8ab4ff); | |
| 402 | 499 | border-radius: var(--sv-radius-control, 0.35rem); | |
| @@ -430,9 +527,33 @@ body { | |||
| 430 | 527 | opacity: 0.7; | |
| 431 | 528 | } | |
| 432 | 529 | ||
| 530 | + | .sv-button-icon-only { | |
| 531 | + | width: 2rem; | |
| 532 | + | min-width: 2rem; | |
| 533 | + | min-height: 2rem; | |
| 534 | + | padding: 0.35rem; | |
| 535 | + | } | |
| 536 | + | ||
| 537 | + | .sv-danger-link { | |
| 538 | + | border: 0; | |
| 539 | + | padding: 0.15rem 0.2rem; | |
| 540 | + | background: transparent; | |
| 541 | + | color: var(--sv-danger-text, #fecaca); | |
| 542 | + | cursor: pointer; | |
| 543 | + | font: inherit; | |
| 544 | + | font-weight: 650; | |
| 545 | + | text-decoration: none; | |
| 546 | + | } | |
| 547 | + | ||
| 548 | + | .sv-danger-link:hover { | |
| 549 | + | color: var(--sv-danger, #ef4444); | |
| 550 | + | text-decoration: underline; | |
| 551 | + | } | |
| 552 | + | ||
| 433 | 553 | .sv-button:focus-visible, | |
| 434 | 554 | .sv-icon-button:focus-visible, | |
| 435 | 555 | .sv-domain-filter:focus-visible, | |
| 556 | + | .sv-danger-link:focus-visible, | |
| 436 | 557 | .sv-filter-chip button:focus-visible, | |
| 437 | 558 | .sv-link:focus-visible, | |
| 438 | 559 | .sv-search-label input:focus-visible, | |
| @@ -482,11 +603,35 @@ body { | |||
| 482 | 603 | display: inline-flex; | |
| 483 | 604 | align-items: center; | |
| 484 | 605 | justify-content: center; | |
| 485 | - | width: 1.4rem; | |
| 486 | - | height: 1.4rem; | |
| 606 | + | width: 1.75rem; | |
| 607 | + | height: 1.75rem; | |
| 487 | 608 | border-radius: 999px; | |
| 488 | 609 | } | |
| 489 | 610 | ||
| 611 | + | .sv-icon { | |
| 612 | + | flex: 0 0 auto; | |
| 613 | + | width: 1rem; | |
| 614 | + | height: 1rem; | |
| 615 | + | stroke-width: 2.15; | |
| 616 | + | } | |
| 617 | + | ||
| 618 | + | .sv-icon-button .sv-icon { | |
| 619 | + | width: 0.95rem; | |
| 620 | + | height: 0.95rem; | |
| 621 | + | } | |
| 622 | + | ||
| 623 | + | .sv-sr-only { | |
| 624 | + | position: absolute; | |
| 625 | + | width: 1px; | |
| 626 | + | height: 1px; | |
| 627 | + | padding: 0; | |
| 628 | + | margin: -1px; | |
| 629 | + | overflow: hidden; | |
| 630 | + | clip: rect(0, 0, 0, 0); | |
| 631 | + | white-space: nowrap; | |
| 632 | + | border: 0; | |
| 633 | + | } | |
| 634 | + | ||
| 490 | 635 | .sv-danger { | |
| 491 | 636 | color: var(--sv-danger-text, #fecaca); | |
| 492 | 637 | } | |
| @@ -497,6 +642,44 @@ body { | |||
| 497 | 642 | background: color-mix(in srgb, var(--sv-accent, #8ab4ff) 12%, transparent); | |
| 498 | 643 | } | |
| 499 | 644 | ||
| 645 | + | .sv-confirm-backdrop { | |
| 646 | + | position: fixed; | |
| 647 | + | inset: 0; | |
| 648 | + | z-index: 1000; | |
| 649 | + | display: grid; | |
| 650 | + | place-items: center; | |
| 651 | + | padding: 1rem; | |
| 652 | + | background: rgba(0, 0, 0, 0.55); | |
| 653 | + | backdrop-filter: blur(2px); | |
| 654 | + | } | |
| 655 | + | ||
| 656 | + | .sv-confirm-dialog { | |
| 657 | + | width: min(26rem, 100%); | |
| 658 | + | border: 1px solid color-mix(in srgb, var(--sv-danger, #ef4444) 48%, var(--sv-border, #2a313d)); | |
| 659 | + | border-radius: var(--sv-radius-card, 0.5rem); | |
| 660 | + | padding: 1rem; | |
| 661 | + | background: var(--sv-surface, #171a21); | |
| 662 | + | color: var(--sv-text, #e8ebf0); | |
| 663 | + | box-shadow: 0 1.25rem 3rem rgba(0, 0, 0, 0.35); | |
| 664 | + | } | |
| 665 | + | ||
| 666 | + | .sv-confirm-dialog h3 { | |
| 667 | + | margin: 0; | |
| 668 | + | font-size: 1rem; | |
| 669 | + | } | |
| 670 | + | ||
| 671 | + | .sv-confirm-dialog p { | |
| 672 | + | margin: 0.65rem 0 0; | |
| 673 | + | color: var(--sv-muted, #9aa3b2); | |
| 674 | + | } | |
| 675 | + | ||
| 676 | + | .sv-confirm-actions { | |
| 677 | + | display: flex; | |
| 678 | + | justify-content: flex-end; | |
| 679 | + | gap: 0.5rem; | |
| 680 | + | margin-top: 1rem; | |
| 681 | + | } | |
| 682 | + | ||
| 500 | 683 | .sv-history-item.sv-focused { | |
| 501 | 684 | outline: 2px solid var(--sv-accent, #8ab4ff); | |
| 502 | 685 | outline-offset: 0.35rem; | |
M cmd/systemviews/bootstrap_test.go +11 -1
| @@ -3,6 +3,7 @@ package main | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "testing" | |
| 6 | + | "time" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/dumber/internal/application/dto" | |
| 8 | 9 | "github.com/bnema/dumber/internal/application/port" | |
| @@ -48,7 +49,7 @@ func TestNewBridgeApp_UsesCurrentConfigForNonConfigRoutes(t *testing.T) { | |||
| 48 | 49 | app := newBridgeApp(dom, "dumb://history", bridge) | |
| 49 | 50 | ||
| 50 | 51 | require.NoError(t, app.Run()) | |
| 51 | - | assert.True(t, bridge.calledHistory) | |
| 52 | + | require.Eventually(t, func() bool { return bridge.calledHistory }, time.Second, 10*time.Millisecond) | |
| 52 | 53 | assert.True(t, bridge.calledConfig) | |
| 53 | 54 | assert.False(t, bridge.calledKeybindings) | |
| 54 | 55 | assert.Contains(t, dom.html, `class="sv-app sv-dark"`) | |
| @@ -123,6 +124,11 @@ func (f *fakeBridgeService) TimelineByDomain(context.Context, string, int, int) | |||
| 123 | 124 | return f.historyEntries, nil | |
| 124 | 125 | } | |
| 125 | 126 | ||
| 127 | + | func (f *fakeBridgeService) TimelineWindow(_ context.Context, before time.Time, _ string) (*entity.HistoryWindow, error) { | |
| 128 | + | f.calledHistory = true | |
| 129 | + | return &entity.HistoryWindow{Entries: f.historyEntries, Before: before, After: before.Add(-24 * time.Hour)}, nil | |
| 130 | + | } | |
| 131 | + | ||
| 126 | 132 | func (*fakeBridgeService) Search(context.Context, string, int) ([]*entity.HistoryEntry, error) { | |
| 127 | 133 | return nil, nil | |
| 128 | 134 | } | |
| @@ -131,6 +137,10 @@ func (*fakeBridgeService) DeleteEntry(context.Context, int64) error { return nil | |||
| 131 | 137 | ||
| 132 | 138 | func (*fakeBridgeService) DeleteRange(context.Context, string) error { return nil } | |
| 133 | 139 | ||
| 140 | + | func (*fakeBridgeService) Stats(context.Context) (*entity.HistoryStats, error) { | |
| 141 | + | return nil, nil | |
| 142 | + | } | |
| 143 | + | ||
| 134 | 144 | func (*fakeBridgeService) Analytics(context.Context) (*entity.HistoryAnalytics, error) { | |
| 135 | 145 | return nil, nil | |
| 136 | 146 | } | |
M internal/application/port/favicon_service.go +7 -0
| @@ -9,6 +9,7 @@ type FaviconService interface { | |||
| 9 | 9 | DiskPathPNG(domain string) string | |
| 10 | 10 | HasPNGOnDisk(domain string) bool | |
| 11 | 11 | HasPNGSizedOnDisk(domain string, size int) bool | |
| 12 | + | DiskPathPNGSized(domain string, size int) string | |
| 12 | 13 | EnsureSizedPNG(ctx context.Context, domain string, size int) error | |
| 13 | 14 | EnsureCacheDir() error | |
| 14 | 15 | // EnsureDiskCache ensures a favicon is written to the on-disk cache for the given | |
| @@ -17,3 +18,9 @@ type FaviconService interface { | |||
| 17 | 18 | EnsureDiskCache(ctx context.Context, domain string) | |
| 18 | 19 | Close() | |
| 19 | 20 | } | |
| 21 | + | ||
| 22 | + | // SystemviewFaviconServiceSetter is an optional engine capability that lets the | |
| 23 | + | // UI expose cached favicons to internal systemview pages without remote image loads. | |
| 24 | + | type SystemviewFaviconServiceSetter interface { | |
| 25 | + | SetSystemviewFaviconService(service FaviconService) | |
| 26 | + | } | |
M internal/application/port/homepage.go +2 -0
| @@ -22,11 +22,13 @@ type HistorySearchOutput struct { | |||
| 22 | 22 | type HomepageHistory interface { | |
| 23 | 23 | GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) | |
| 24 | 24 | GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) | |
| 25 | + | GetRecentWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) | |
| 25 | 26 | Search(ctx context.Context, input HistorySearchInput) (*HistorySearchOutput, error) | |
| 26 | 27 | Delete(ctx context.Context, id int64) error | |
| 27 | 28 | ClearRange(ctx context.Context, rangeID string) error | |
| 28 | 29 | ClearAll(ctx context.Context) error | |
| 29 | 30 | ClearOlderThan(ctx context.Context, before time.Time) error | |
| 31 | + | GetStats(ctx context.Context) (*entity.HistoryStats, error) | |
| 30 | 32 | GetAnalytics(ctx context.Context) (*entity.HistoryAnalytics, error) | |
| 31 | 33 | GetDomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error) | |
| 32 | 34 | DeleteByDomain(ctx context.Context, domain string) error | |
M internal/application/port/mocks/mocks.go +193 -0
| @@ -5052,6 +5052,63 @@ func (_c *MockFaviconService_DiskPathPNG_Call) RunAndReturn(run func(domain stri | |||
| 5052 | 5052 | return _c | |
| 5053 | 5053 | } | |
| 5054 | 5054 | ||
| 5055 | + | // DiskPathPNGSized provides a mock function for the type MockFaviconService | |
| 5056 | + | func (_mock *MockFaviconService) DiskPathPNGSized(domain string, size int) string { | |
| 5057 | + | ret := _mock.Called(domain, size) | |
| 5058 | + | ||
| 5059 | + | if len(ret) == 0 { | |
| 5060 | + | panic("no return value specified for DiskPathPNGSized") | |
| 5061 | + | } | |
| 5062 | + | ||
| 5063 | + | var r0 string | |
| 5064 | + | if returnFunc, ok := ret.Get(0).(func(string, int) string); ok { | |
| 5065 | + | r0 = returnFunc(domain, size) | |
| 5066 | + | } else { | |
| 5067 | + | r0 = ret.Get(0).(string) | |
| 5068 | + | } | |
| 5069 | + | return r0 | |
| 5070 | + | } | |
| 5071 | + | ||
| 5072 | + | // MockFaviconService_DiskPathPNGSized_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DiskPathPNGSized' | |
| 5073 | + | type MockFaviconService_DiskPathPNGSized_Call struct { | |
| 5074 | + | *mock.Call | |
| 5075 | + | } | |
| 5076 | + | ||
| 5077 | + | // DiskPathPNGSized is a helper method to define mock.On call | |
| 5078 | + | // - domain string | |
| 5079 | + | // - size int | |
| 5080 | + | func (_e *MockFaviconService_Expecter) DiskPathPNGSized(domain interface{}, size interface{}) *MockFaviconService_DiskPathPNGSized_Call { | |
| 5081 | + | return &MockFaviconService_DiskPathPNGSized_Call{Call: _e.mock.On("DiskPathPNGSized", domain, size)} | |
| 5082 | + | } | |
| 5083 | + | ||
| 5084 | + | func (_c *MockFaviconService_DiskPathPNGSized_Call) Run(run func(domain string, size int)) *MockFaviconService_DiskPathPNGSized_Call { | |
| 5085 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 5086 | + | var arg0 string | |
| 5087 | + | if args[0] != nil { | |
| 5088 | + | arg0 = args[0].(string) | |
| 5089 | + | } | |
| 5090 | + | var arg1 int | |
| 5091 | + | if args[1] != nil { | |
| 5092 | + | arg1 = args[1].(int) | |
| 5093 | + | } | |
| 5094 | + | run( | |
| 5095 | + | arg0, | |
| 5096 | + | arg1, | |
| 5097 | + | ) | |
| 5098 | + | }) | |
| 5099 | + | return _c | |
| 5100 | + | } | |
| 5101 | + | ||
| 5102 | + | func (_c *MockFaviconService_DiskPathPNGSized_Call) Return(s string) *MockFaviconService_DiskPathPNGSized_Call { | |
| 5103 | + | _c.Call.Return(s) | |
| 5104 | + | return _c | |
| 5105 | + | } | |
| 5106 | + | ||
| 5107 | + | func (_c *MockFaviconService_DiskPathPNGSized_Call) RunAndReturn(run func(domain string, size int) string) *MockFaviconService_DiskPathPNGSized_Call { | |
| 5108 | + | _c.Call.Return(run) | |
| 5109 | + | return _c | |
| 5110 | + | } | |
| 5111 | + | ||
| 5055 | 5112 | // EnsureCacheDir provides a mock function for the type MockFaviconService | |
| 5056 | 5113 | func (_mock *MockFaviconService) EnsureCacheDir() error { | |
| 5057 | 5114 | ret := _mock.Called() | |
| @@ -7002,6 +7059,142 @@ func (_c *MockHomepageHistory_GetRecentByDomain_Call) RunAndReturn(run func(ctx | |||
| 7002 | 7059 | return _c | |
| 7003 | 7060 | } | |
| 7004 | 7061 | ||
| 7062 | + | // GetRecentWindow provides a mock function for the type MockHomepageHistory | |
| 7063 | + | func (_mock *MockHomepageHistory) GetRecentWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) { | |
| 7064 | + | ret := _mock.Called(ctx, before, domain) | |
| 7065 | + | ||
| 7066 | + | if len(ret) == 0 { | |
| 7067 | + | panic("no return value specified for GetRecentWindow") | |
| 7068 | + | } | |
| 7069 | + | ||
| 7070 | + | var r0 *entity.HistoryWindow | |
| 7071 | + | var r1 error | |
| 7072 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, string) (*entity.HistoryWindow, error)); ok { | |
| 7073 | + | return returnFunc(ctx, before, domain) | |
| 7074 | + | } | |
| 7075 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, string) *entity.HistoryWindow); ok { | |
| 7076 | + | r0 = returnFunc(ctx, before, domain) | |
| 7077 | + | } else { | |
| 7078 | + | if ret.Get(0) != nil { | |
| 7079 | + | r0 = ret.Get(0).(*entity.HistoryWindow) | |
| 7080 | + | } | |
| 7081 | + | } | |
| 7082 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time, string) error); ok { | |
| 7083 | + | r1 = returnFunc(ctx, before, domain) | |
| 7084 | + | } else { | |
| 7085 | + | r1 = ret.Error(1) | |
| 7086 | + | } | |
| 7087 | + | return r0, r1 | |
| 7088 | + | } | |
| 7089 | + | ||
| 7090 | + | // MockHomepageHistory_GetRecentWindow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRecentWindow' | |
| 7091 | + | type MockHomepageHistory_GetRecentWindow_Call struct { | |
| 7092 | + | *mock.Call | |
| 7093 | + | } | |
| 7094 | + | ||
| 7095 | + | // GetRecentWindow is a helper method to define mock.On call | |
| 7096 | + | // - ctx context.Context | |
| 7097 | + | // - before time.Time | |
| 7098 | + | // - domain string | |
| 7099 | + | func (_e *MockHomepageHistory_Expecter) GetRecentWindow(ctx interface{}, before interface{}, domain interface{}) *MockHomepageHistory_GetRecentWindow_Call { | |
| 7100 | + | return &MockHomepageHistory_GetRecentWindow_Call{Call: _e.mock.On("GetRecentWindow", ctx, before, domain)} | |
| 7101 | + | } | |
| 7102 | + | ||
| 7103 | + | func (_c *MockHomepageHistory_GetRecentWindow_Call) Run(run func(ctx context.Context, before time.Time, domain string)) *MockHomepageHistory_GetRecentWindow_Call { | |
| 7104 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 7105 | + | var arg0 context.Context | |
| 7106 | + | if args[0] != nil { | |
| 7107 | + | arg0 = args[0].(context.Context) | |
| 7108 | + | } | |
| 7109 | + | var arg1 time.Time | |
| 7110 | + | if args[1] != nil { | |
| 7111 | + | arg1 = args[1].(time.Time) | |
| 7112 | + | } | |
| 7113 | + | var arg2 string | |
| 7114 | + | if args[2] != nil { | |
| 7115 | + | arg2 = args[2].(string) | |
| 7116 | + | } | |
| 7117 | + | run( | |
| 7118 | + | arg0, | |
| 7119 | + | arg1, | |
| 7120 | + | arg2, | |
| 7121 | + | ) | |
| 7122 | + | }) | |
| 7123 | + | return _c | |
| 7124 | + | } | |
| 7125 | + | ||
| 7126 | + | func (_c *MockHomepageHistory_GetRecentWindow_Call) Return(historyWindow *entity.HistoryWindow, err error) *MockHomepageHistory_GetRecentWindow_Call { | |
| 7127 | + | _c.Call.Return(historyWindow, err) | |
| 7128 | + | return _c | |
| 7129 | + | } | |
| 7130 | + | ||
| 7131 | + | func (_c *MockHomepageHistory_GetRecentWindow_Call) RunAndReturn(run func(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error)) *MockHomepageHistory_GetRecentWindow_Call { | |
| 7132 | + | _c.Call.Return(run) | |
| 7133 | + | return _c | |
| 7134 | + | } | |
| 7135 | + | ||
| 7136 | + | // GetStats provides a mock function for the type MockHomepageHistory | |
| 7137 | + | func (_mock *MockHomepageHistory) GetStats(ctx context.Context) (*entity.HistoryStats, error) { | |
| 7138 | + | ret := _mock.Called(ctx) | |
| 7139 | + | ||
| 7140 | + | if len(ret) == 0 { | |
| 7141 | + | panic("no return value specified for GetStats") | |
| 7142 | + | } | |
| 7143 | + | ||
| 7144 | + | var r0 *entity.HistoryStats | |
| 7145 | + | var r1 error | |
| 7146 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (*entity.HistoryStats, error)); ok { | |
| 7147 | + | return returnFunc(ctx) | |
| 7148 | + | } | |
| 7149 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) *entity.HistoryStats); ok { | |
| 7150 | + | r0 = returnFunc(ctx) | |
| 7151 | + | } else { | |
| 7152 | + | if ret.Get(0) != nil { | |
| 7153 | + | r0 = ret.Get(0).(*entity.HistoryStats) | |
| 7154 | + | } | |
| 7155 | + | } | |
| 7156 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 7157 | + | r1 = returnFunc(ctx) | |
| 7158 | + | } else { | |
| 7159 | + | r1 = ret.Error(1) | |
| 7160 | + | } | |
| 7161 | + | return r0, r1 | |
| 7162 | + | } | |
| 7163 | + | ||
| 7164 | + | // MockHomepageHistory_GetStats_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetStats' | |
| 7165 | + | type MockHomepageHistory_GetStats_Call struct { | |
| 7166 | + | *mock.Call | |
| 7167 | + | } | |
| 7168 | + | ||
| 7169 | + | // GetStats is a helper method to define mock.On call | |
| 7170 | + | // - ctx context.Context | |
| 7171 | + | func (_e *MockHomepageHistory_Expecter) GetStats(ctx interface{}) *MockHomepageHistory_GetStats_Call { | |
| 7172 | + | return &MockHomepageHistory_GetStats_Call{Call: _e.mock.On("GetStats", ctx)} | |
| 7173 | + | } | |
| 7174 | + | ||
| 7175 | + | func (_c *MockHomepageHistory_GetStats_Call) Run(run func(ctx context.Context)) *MockHomepageHistory_GetStats_Call { | |
| 7176 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 7177 | + | var arg0 context.Context | |
| 7178 | + | if args[0] != nil { | |
| 7179 | + | arg0 = args[0].(context.Context) | |
| 7180 | + | } | |
| 7181 | + | run( | |
| 7182 | + | arg0, | |
| 7183 | + | ) | |
| 7184 | + | }) | |
| 7185 | + | return _c | |
| 7186 | + | } | |
| 7187 | + | ||
| 7188 | + | func (_c *MockHomepageHistory_GetStats_Call) Return(historyStats *entity.HistoryStats, err error) *MockHomepageHistory_GetStats_Call { | |
| 7189 | + | _c.Call.Return(historyStats, err) | |
| 7190 | + | return _c | |
| 7191 | + | } | |
| 7192 | + | ||
| 7193 | + | func (_c *MockHomepageHistory_GetStats_Call) RunAndReturn(run func(ctx context.Context) (*entity.HistoryStats, error)) *MockHomepageHistory_GetStats_Call { | |
| 7194 | + | _c.Call.Return(run) | |
| 7195 | + | return _c | |
| 7196 | + | } | |
| 7197 | + | ||
| 7005 | 7198 | // Search provides a mock function for the type MockHomepageHistory | |
| 7006 | 7199 | func (_mock *MockHomepageHistory) Search(ctx context.Context, input port.HistorySearchInput) (*port.HistorySearchOutput, error) { | |
| 7007 | 7200 | ret := _mock.Called(ctx, input) | |
M internal/application/port/systemviews.go +3 -0
| @@ -2,6 +2,7 @@ package port | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "time" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/dumber/internal/application/dto" | |
| 7 | 8 | "github.com/bnema/dumber/internal/domain/entity" | |
| @@ -11,9 +12,11 @@ import ( | |||
| 11 | 12 | type SystemviewHistoryService interface { | |
| 12 | 13 | Timeline(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) | |
| 13 | 14 | TimelineByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) | |
| 15 | + | TimelineWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) | |
| 14 | 16 | Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error) | |
| 15 | 17 | DeleteEntry(ctx context.Context, id int64) error | |
| 16 | 18 | DeleteRange(ctx context.Context, rangeID string) error | |
| 19 | + | Stats(ctx context.Context) (*entity.HistoryStats, error) | |
| 17 | 20 | Analytics(ctx context.Context) (*entity.HistoryAnalytics, error) | |
| 18 | 21 | DomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error) | |
| 19 | 22 | DeleteDomain(ctx context.Context, domain string) error | |
M internal/application/usecase/search_history.go +55 -5
| @@ -16,6 +16,8 @@ import ( | |||
| 16 | 16 | // Compile-time check: SearchHistoryUseCase must satisfy port.HomepageHistory. | |
| 17 | 17 | var _ port.HomepageHistory = (*SearchHistoryUseCase)(nil) | |
| 18 | 18 | ||
| 19 | + | const historyWindowDuration = 24 * time.Hour | |
| 20 | + | ||
| 19 | 21 | // SearchHistoryUseCase handles history search and retrieval operations. | |
| 20 | 22 | type SearchHistoryUseCase struct { | |
| 21 | 23 | historyRepo repository.HistoryRepository | |
| @@ -62,10 +64,11 @@ func (uc *SearchHistoryUseCase) Search(ctx context.Context, input SearchInput) ( | |||
| 62 | 64 | return &SearchOutput{Matches: matches}, nil | |
| 63 | 65 | } | |
| 64 | 66 | ||
| 65 | - | // GetRecent retrieves recent history entries with pagination. | |
| 67 | + | // GetRecent retrieves recent history entries. A zero limit means all entries; | |
| 68 | + | // negative limits retain the historical default page size. | |
| 66 | 69 | func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { | |
| 67 | - | if limit <= 0 { | |
| 68 | - | limit = 50 // Default limit | |
| 70 | + | if limit < 0 { | |
| 71 | + | limit = 50 // Default limit for invalid negative values. | |
| 69 | 72 | } | |
| 70 | 73 | ||
| 71 | 74 | entries, err := uc.historyRepo.GetRecent(ctx, limit, offset) | |
| @@ -76,9 +79,10 @@ func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int | |||
| 76 | 79 | return entries, nil | |
| 77 | 80 | } | |
| 78 | 81 | ||
| 79 | - | // GetRecentByDomain retrieves recent history entries for a canonical domain with pagination. | |
| 82 | + | // GetRecentByDomain retrieves recent history entries for a canonical domain. A | |
| 83 | + | // zero limit means all matching entries; negative limits use the default page size. | |
| 80 | 84 | func (uc *SearchHistoryUseCase) GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) { | |
| 81 | - | if limit <= 0 { | |
| 85 | + | if limit < 0 { | |
| 82 | 86 | limit = 50 | |
| 83 | 87 | } | |
| 84 | 88 | domain, err := canonicalHistoryDomain(domain) | |
| @@ -103,6 +107,43 @@ func canonicalHistoryDomain(domain string) (string, error) { | |||
| 103 | 107 | return domain, nil | |
| 104 | 108 | } | |
| 105 | 109 | ||
| 110 | + | // GetRecentWindow retrieves the 24-hour history window ending before the given cursor. | |
| 111 | + | func (uc *SearchHistoryUseCase) GetRecentWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) { | |
| 112 | + | if before.IsZero() { | |
| 113 | + | before = time.Now() | |
| 114 | + | } | |
| 115 | + | before = before.UTC() | |
| 116 | + | after := before.Add(-historyWindowDuration) | |
| 117 | + | ||
| 118 | + | var ( | |
| 119 | + | entries []*entity.HistoryEntry | |
| 120 | + | hasMore bool | |
| 121 | + | err error | |
| 122 | + | ) | |
| 123 | + | domain = domainurl.CanonicalDomain(domain) | |
| 124 | + | if domain != "" { | |
| 125 | + | entries, err = uc.historyRepo.GetRecentWindowByDomain(ctx, domain, before, after) | |
| 126 | + | if err == nil { | |
| 127 | + | hasMore, err = uc.historyRepo.HasEntriesByDomainBefore(ctx, domain, after) | |
| 128 | + | } | |
| 129 | + | } else { | |
| 130 | + | entries, err = uc.historyRepo.GetRecentWindow(ctx, before, after) | |
| 131 | + | if err == nil { | |
| 132 | + | hasMore, err = uc.historyRepo.HasEntriesBefore(ctx, after) | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | if err != nil { | |
| 136 | + | return nil, fmt.Errorf("failed to get recent history window: %w", err) | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | return &entity.HistoryWindow{ | |
| 140 | + | Entries: entries, | |
| 141 | + | Before: before, | |
| 142 | + | After: after, | |
| 143 | + | HasMore: hasMore, | |
| 144 | + | }, nil | |
| 145 | + | } | |
| 146 | + | ||
| 106 | 147 | // GetRecentSince retrieves history entries visited within the last N days. | |
| 107 | 148 | // If days is 0, returns all history entries. | |
| 108 | 149 | // If days is negative, defaults to 30 days. | |
| @@ -258,6 +299,15 @@ func (uc *SearchHistoryUseCase) GetDomainStats(ctx context.Context, limit int) ( | |||
| 258 | 299 | return uc.historyRepo.GetDomainStats(ctx, limit) | |
| 259 | 300 | } | |
| 260 | 301 | ||
| 302 | + | // GetStats retrieves lightweight aggregate history statistics. | |
| 303 | + | func (uc *SearchHistoryUseCase) GetStats(ctx context.Context) (*entity.HistoryStats, error) { | |
| 304 | + | stats, err := uc.historyRepo.GetStats(ctx) | |
| 305 | + | if err != nil { | |
| 306 | + | return nil, fmt.Errorf("failed to get history stats: %w", err) | |
| 307 | + | } | |
| 308 | + | return stats, nil | |
| 309 | + | } | |
| 310 | + | ||
| 261 | 311 | // GetAnalytics retrieves aggregated history analytics for the homepage. | |
| 262 | 312 | func (uc *SearchHistoryUseCase) GetAnalytics(ctx context.Context) (*entity.HistoryAnalytics, error) { | |
| 263 | 313 | log := logging.FromContext(ctx) | |
M internal/application/usecase/search_history_test.go +25 -0
| @@ -13,6 +13,31 @@ import ( | |||
| 13 | 13 | "github.com/stretchr/testify/require" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | + | func TestSearchHistoryUseCase_GetRecent_ZeroLimitMeansAll(t *testing.T) { | |
| 17 | + | ctx := testContext() | |
| 18 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 19 | + | entries := []*entity.HistoryEntry{{ID: 1, URL: "https://example.com", Title: "Example"}} | |
| 20 | + | historyRepo.EXPECT().GetRecent(mock.Anything, 0, 0).Return(entries, nil).Once() | |
| 21 | + | ||
| 22 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 23 | + | result, err := uc.GetRecent(ctx, 0, 0) | |
| 24 | + | ||
| 25 | + | require.NoError(t, err) | |
| 26 | + | require.Len(t, result, 1) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestSearchHistoryUseCase_GetRecent_NegativeLimitDefaultsToPageSize(t *testing.T) { | |
| 30 | + | ctx := testContext() | |
| 31 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 32 | + | historyRepo.EXPECT().GetRecent(mock.Anything, 50, 0).Return([]*entity.HistoryEntry{}, nil).Once() | |
| 33 | + | ||
| 34 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 35 | + | result, err := uc.GetRecent(ctx, -1, 0) | |
| 36 | + | ||
| 37 | + | require.NoError(t, err) | |
| 38 | + | assert.Empty(t, result) | |
| 39 | + | } | |
| 40 | + | ||
| 16 | 41 | func TestSearchHistoryUseCase_GetRecentSince_ReturnsEntries(t *testing.T) { | |
| 17 | 42 | ctx := testContext() | |
| 18 | 43 | ||
A internal/domain/entity/history_window.go +11 -0
| @@ -0,0 +1,11 @@ | |||
| 1 | + | package entity | |
| 2 | + | ||
| 3 | + | import "time" | |
| 4 | + | ||
| 5 | + | // HistoryWindow contains one fixed-duration slice of browsing history. | |
| 6 | + | type HistoryWindow struct { | |
| 7 | + | Entries []*HistoryEntry `json:"entries"` | |
| 8 | + | Before time.Time `json:"before"` | |
| 9 | + | After time.Time `json:"after"` | |
| 10 | + | HasMore bool `json:"has_more"` | |
| 11 | + | } | |
M internal/domain/repository/history.go +14 -2
| @@ -18,12 +18,24 @@ type HistoryRepository interface { | |||
| 18 | 18 | // Search performs a fuzzy search on history entries. | |
| 19 | 19 | Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) | |
| 20 | 20 | ||
| 21 | - | // GetRecent retrieves recent history entries with pagination. | |
| 21 | + | // GetRecent retrieves recent history entries with pagination. A zero limit means all entries. | |
| 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. | |
| 24 | + | // GetRecentByDomain retrieves recent history entries for a domain with pagination. A zero limit means all matching entries. | |
| 25 | 25 | GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) | |
| 26 | 26 | ||
| 27 | + | // GetRecentWindow retrieves entries visited in [after, before), sorted by recency. | |
| 28 | + | GetRecentWindow(ctx context.Context, before, after time.Time) ([]*entity.HistoryEntry, error) | |
| 29 | + | ||
| 30 | + | // GetRecentWindowByDomain retrieves domain entries visited in [after, before), sorted by recency. | |
| 31 | + | GetRecentWindowByDomain(ctx context.Context, domain string, before, after time.Time) ([]*entity.HistoryEntry, error) | |
| 32 | + | ||
| 33 | + | // HasEntriesBefore reports whether any history entry was visited before the given time. | |
| 34 | + | HasEntriesBefore(ctx context.Context, before time.Time) (bool, error) | |
| 35 | + | ||
| 36 | + | // HasEntriesByDomainBefore reports whether any domain entry was visited before the given time. | |
| 37 | + | HasEntriesByDomainBefore(ctx context.Context, domain string, before time.Time) (bool, error) | |
| 38 | + | ||
| 27 | 39 | // GetRecentSince retrieves history entries visited within the last N days. | |
| 28 | 40 | // days must be > 0. | |
| 29 | 41 | GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) | |
M internal/domain/repository/mocks/mocks.go +292 -0
| @@ -2866,6 +2866,160 @@ func (_c *MockHistoryRepository_GetRecentSince_Call) RunAndReturn(run func(ctx c | |||
| 2866 | 2866 | return _c | |
| 2867 | 2867 | } | |
| 2868 | 2868 | ||
| 2869 | + | // GetRecentWindow provides a mock function for the type MockHistoryRepository | |
| 2870 | + | func (_mock *MockHistoryRepository) GetRecentWindow(ctx context.Context, before time.Time, after time.Time) ([]*entity.HistoryEntry, error) { | |
| 2871 | + | ret := _mock.Called(ctx, before, after) | |
| 2872 | + | ||
| 2873 | + | if len(ret) == 0 { | |
| 2874 | + | panic("no return value specified for GetRecentWindow") | |
| 2875 | + | } | |
| 2876 | + | ||
| 2877 | + | var r0 []*entity.HistoryEntry | |
| 2878 | + | var r1 error | |
| 2879 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, time.Time) ([]*entity.HistoryEntry, error)); ok { | |
| 2880 | + | return returnFunc(ctx, before, after) | |
| 2881 | + | } | |
| 2882 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, time.Time) []*entity.HistoryEntry); ok { | |
| 2883 | + | r0 = returnFunc(ctx, before, after) | |
| 2884 | + | } else { | |
| 2885 | + | if ret.Get(0) != nil { | |
| 2886 | + | r0 = ret.Get(0).([]*entity.HistoryEntry) | |
| 2887 | + | } | |
| 2888 | + | } | |
| 2889 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time, time.Time) error); ok { | |
| 2890 | + | r1 = returnFunc(ctx, before, after) | |
| 2891 | + | } else { | |
| 2892 | + | r1 = ret.Error(1) | |
| 2893 | + | } | |
| 2894 | + | return r0, r1 | |
| 2895 | + | } | |
| 2896 | + | ||
| 2897 | + | // MockHistoryRepository_GetRecentWindow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRecentWindow' | |
| 2898 | + | type MockHistoryRepository_GetRecentWindow_Call struct { | |
| 2899 | + | *mock.Call | |
| 2900 | + | } | |
| 2901 | + | ||
| 2902 | + | // GetRecentWindow is a helper method to define mock.On call | |
| 2903 | + | // - ctx context.Context | |
| 2904 | + | // - before time.Time | |
| 2905 | + | // - after time.Time | |
| 2906 | + | func (_e *MockHistoryRepository_Expecter) GetRecentWindow(ctx interface{}, before interface{}, after interface{}) *MockHistoryRepository_GetRecentWindow_Call { | |
| 2907 | + | return &MockHistoryRepository_GetRecentWindow_Call{Call: _e.mock.On("GetRecentWindow", ctx, before, after)} | |
| 2908 | + | } | |
| 2909 | + | ||
| 2910 | + | func (_c *MockHistoryRepository_GetRecentWindow_Call) Run(run func(ctx context.Context, before time.Time, after time.Time)) *MockHistoryRepository_GetRecentWindow_Call { | |
| 2911 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 2912 | + | var arg0 context.Context | |
| 2913 | + | if args[0] != nil { | |
| 2914 | + | arg0 = args[0].(context.Context) | |
| 2915 | + | } | |
| 2916 | + | var arg1 time.Time | |
| 2917 | + | if args[1] != nil { | |
| 2918 | + | arg1 = args[1].(time.Time) | |
| 2919 | + | } | |
| 2920 | + | var arg2 time.Time | |
| 2921 | + | if args[2] != nil { | |
| 2922 | + | arg2 = args[2].(time.Time) | |
| 2923 | + | } | |
| 2924 | + | run( | |
| 2925 | + | arg0, | |
| 2926 | + | arg1, | |
| 2927 | + | arg2, | |
| 2928 | + | ) | |
| 2929 | + | }) | |
| 2930 | + | return _c | |
| 2931 | + | } | |
| 2932 | + | ||
| 2933 | + | func (_c *MockHistoryRepository_GetRecentWindow_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetRecentWindow_Call { | |
| 2934 | + | _c.Call.Return(historyEntrys, err) | |
| 2935 | + | return _c | |
| 2936 | + | } | |
| 2937 | + | ||
| 2938 | + | func (_c *MockHistoryRepository_GetRecentWindow_Call) RunAndReturn(run func(ctx context.Context, before time.Time, after time.Time) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecentWindow_Call { | |
| 2939 | + | _c.Call.Return(run) | |
| 2940 | + | return _c | |
| 2941 | + | } | |
| 2942 | + | ||
| 2943 | + | // GetRecentWindowByDomain provides a mock function for the type MockHistoryRepository | |
| 2944 | + | func (_mock *MockHistoryRepository) GetRecentWindowByDomain(ctx context.Context, domain string, before time.Time, after time.Time) ([]*entity.HistoryEntry, error) { | |
| 2945 | + | ret := _mock.Called(ctx, domain, before, after) | |
| 2946 | + | ||
| 2947 | + | if len(ret) == 0 { | |
| 2948 | + | panic("no return value specified for GetRecentWindowByDomain") | |
| 2949 | + | } | |
| 2950 | + | ||
| 2951 | + | var r0 []*entity.HistoryEntry | |
| 2952 | + | var r1 error | |
| 2953 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time, time.Time) ([]*entity.HistoryEntry, error)); ok { | |
| 2954 | + | return returnFunc(ctx, domain, before, after) | |
| 2955 | + | } | |
| 2956 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time, time.Time) []*entity.HistoryEntry); ok { | |
| 2957 | + | r0 = returnFunc(ctx, domain, before, after) | |
| 2958 | + | } else { | |
| 2959 | + | if ret.Get(0) != nil { | |
| 2960 | + | r0 = ret.Get(0).([]*entity.HistoryEntry) | |
| 2961 | + | } | |
| 2962 | + | } | |
| 2963 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, time.Time, time.Time) error); ok { | |
| 2964 | + | r1 = returnFunc(ctx, domain, before, after) | |
| 2965 | + | } else { | |
| 2966 | + | r1 = ret.Error(1) | |
| 2967 | + | } | |
| 2968 | + | return r0, r1 | |
| 2969 | + | } | |
| 2970 | + | ||
| 2971 | + | // MockHistoryRepository_GetRecentWindowByDomain_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRecentWindowByDomain' | |
| 2972 | + | type MockHistoryRepository_GetRecentWindowByDomain_Call struct { | |
| 2973 | + | *mock.Call | |
| 2974 | + | } | |
| 2975 | + | ||
| 2976 | + | // GetRecentWindowByDomain is a helper method to define mock.On call | |
| 2977 | + | // - ctx context.Context | |
| 2978 | + | // - domain string | |
| 2979 | + | // - before time.Time | |
| 2980 | + | // - after time.Time | |
| 2981 | + | func (_e *MockHistoryRepository_Expecter) GetRecentWindowByDomain(ctx interface{}, domain interface{}, before interface{}, after interface{}) *MockHistoryRepository_GetRecentWindowByDomain_Call { | |
| 2982 | + | return &MockHistoryRepository_GetRecentWindowByDomain_Call{Call: _e.mock.On("GetRecentWindowByDomain", ctx, domain, before, after)} | |
| 2983 | + | } | |
| 2984 | + | ||
| 2985 | + | func (_c *MockHistoryRepository_GetRecentWindowByDomain_Call) Run(run func(ctx context.Context, domain string, before time.Time, after time.Time)) *MockHistoryRepository_GetRecentWindowByDomain_Call { | |
| 2986 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 2987 | + | var arg0 context.Context | |
| 2988 | + | if args[0] != nil { | |
| 2989 | + | arg0 = args[0].(context.Context) | |
| 2990 | + | } | |
| 2991 | + | var arg1 string | |
| 2992 | + | if args[1] != nil { | |
| 2993 | + | arg1 = args[1].(string) | |
| 2994 | + | } | |
| 2995 | + | var arg2 time.Time | |
| 2996 | + | if args[2] != nil { | |
| 2997 | + | arg2 = args[2].(time.Time) | |
| 2998 | + | } | |
| 2999 | + | var arg3 time.Time | |
| 3000 | + | if args[3] != nil { | |
| 3001 | + | arg3 = args[3].(time.Time) | |
| 3002 | + | } | |
| 3003 | + | run( | |
| 3004 | + | arg0, | |
| 3005 | + | arg1, | |
| 3006 | + | arg2, | |
| 3007 | + | arg3, | |
| 3008 | + | ) | |
| 3009 | + | }) | |
| 3010 | + | return _c | |
| 3011 | + | } | |
| 3012 | + | ||
| 3013 | + | func (_c *MockHistoryRepository_GetRecentWindowByDomain_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetRecentWindowByDomain_Call { | |
| 3014 | + | _c.Call.Return(historyEntrys, err) | |
| 3015 | + | return _c | |
| 3016 | + | } | |
| 3017 | + | ||
| 3018 | + | func (_c *MockHistoryRepository_GetRecentWindowByDomain_Call) RunAndReturn(run func(ctx context.Context, domain string, before time.Time, after time.Time) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecentWindowByDomain_Call { | |
| 3019 | + | _c.Call.Return(run) | |
| 3020 | + | return _c | |
| 3021 | + | } | |
| 3022 | + | ||
| 2869 | 3023 | // GetStats provides a mock function for the type MockHistoryRepository | |
| 2870 | 3024 | func (_mock *MockHistoryRepository) GetStats(ctx context.Context) (*entity.HistoryStats, error) { | |
| 2871 | 3025 | ret := _mock.Called(ctx) | |
| @@ -2928,6 +3082,144 @@ func (_c *MockHistoryRepository_GetStats_Call) RunAndReturn(run func(ctx context | |||
| 2928 | 3082 | return _c | |
| 2929 | 3083 | } | |
| 2930 | 3084 | ||
| 3085 | + | // HasEntriesBefore provides a mock function for the type MockHistoryRepository | |
| 3086 | + | func (_mock *MockHistoryRepository) HasEntriesBefore(ctx context.Context, before time.Time) (bool, error) { | |
| 3087 | + | ret := _mock.Called(ctx, before) | |
| 3088 | + | ||
| 3089 | + | if len(ret) == 0 { | |
| 3090 | + | panic("no return value specified for HasEntriesBefore") | |
| 3091 | + | } | |
| 3092 | + | ||
| 3093 | + | var r0 bool | |
| 3094 | + | var r1 error | |
| 3095 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) (bool, error)); ok { | |
| 3096 | + | return returnFunc(ctx, before) | |
| 3097 | + | } | |
| 3098 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) bool); ok { | |
| 3099 | + | r0 = returnFunc(ctx, before) | |
| 3100 | + | } else { | |
| 3101 | + | r0 = ret.Get(0).(bool) | |
| 3102 | + | } | |
| 3103 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time) error); ok { | |
| 3104 | + | r1 = returnFunc(ctx, before) | |
| 3105 | + | } else { | |
| 3106 | + | r1 = ret.Error(1) | |
| 3107 | + | } | |
| 3108 | + | return r0, r1 | |
| 3109 | + | } | |
| 3110 | + | ||
| 3111 | + | // MockHistoryRepository_HasEntriesBefore_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasEntriesBefore' | |
| 3112 | + | type MockHistoryRepository_HasEntriesBefore_Call struct { | |
| 3113 | + | *mock.Call | |
| 3114 | + | } | |
| 3115 | + | ||
| 3116 | + | // HasEntriesBefore is a helper method to define mock.On call | |
| 3117 | + | // - ctx context.Context | |
| 3118 | + | // - before time.Time | |
| 3119 | + | func (_e *MockHistoryRepository_Expecter) HasEntriesBefore(ctx interface{}, before interface{}) *MockHistoryRepository_HasEntriesBefore_Call { | |
| 3120 | + | return &MockHistoryRepository_HasEntriesBefore_Call{Call: _e.mock.On("HasEntriesBefore", ctx, before)} | |
| 3121 | + | } | |
| 3122 | + | ||
| 3123 | + | func (_c *MockHistoryRepository_HasEntriesBefore_Call) Run(run func(ctx context.Context, before time.Time)) *MockHistoryRepository_HasEntriesBefore_Call { | |
| 3124 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 3125 | + | var arg0 context.Context | |
| 3126 | + | if args[0] != nil { | |
| 3127 | + | arg0 = args[0].(context.Context) | |
| 3128 | + | } | |
| 3129 | + | var arg1 time.Time | |
| 3130 | + | if args[1] != nil { | |
| 3131 | + | arg1 = args[1].(time.Time) | |
| 3132 | + | } | |
| 3133 | + | run( | |
| 3134 | + | arg0, | |
| 3135 | + | arg1, | |
| 3136 | + | ) | |
| 3137 | + | }) | |
| 3138 | + | return _c | |
| 3139 | + | } | |
| 3140 | + | ||
| 3141 | + | func (_c *MockHistoryRepository_HasEntriesBefore_Call) Return(b bool, err error) *MockHistoryRepository_HasEntriesBefore_Call { | |
| 3142 | + | _c.Call.Return(b, err) | |
| 3143 | + | return _c | |
| 3144 | + | } | |
| 3145 | + | ||
| 3146 | + | func (_c *MockHistoryRepository_HasEntriesBefore_Call) RunAndReturn(run func(ctx context.Context, before time.Time) (bool, error)) *MockHistoryRepository_HasEntriesBefore_Call { | |
| 3147 | + | _c.Call.Return(run) | |
| 3148 | + | return _c | |
| 3149 | + | } | |
| 3150 | + | ||
| 3151 | + | // HasEntriesByDomainBefore provides a mock function for the type MockHistoryRepository | |
| 3152 | + | func (_mock *MockHistoryRepository) HasEntriesByDomainBefore(ctx context.Context, domain string, before time.Time) (bool, error) { | |
| 3153 | + | ret := _mock.Called(ctx, domain, before) | |
| 3154 | + | ||
| 3155 | + | if len(ret) == 0 { | |
| 3156 | + | panic("no return value specified for HasEntriesByDomainBefore") | |
| 3157 | + | } | |
| 3158 | + | ||
| 3159 | + | var r0 bool | |
| 3160 | + | var r1 error | |
| 3161 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time) (bool, error)); ok { | |
| 3162 | + | return returnFunc(ctx, domain, before) | |
| 3163 | + | } | |
| 3164 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time) bool); ok { | |
| 3165 | + | r0 = returnFunc(ctx, domain, before) | |
| 3166 | + | } else { | |
| 3167 | + | r0 = ret.Get(0).(bool) | |
| 3168 | + | } | |
| 3169 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, time.Time) error); ok { | |
| 3170 | + | r1 = returnFunc(ctx, domain, before) | |
| 3171 | + | } else { | |
| 3172 | + | r1 = ret.Error(1) | |
| 3173 | + | } | |
| 3174 | + | return r0, r1 | |
| 3175 | + | } | |
| 3176 | + | ||
| 3177 | + | // MockHistoryRepository_HasEntriesByDomainBefore_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasEntriesByDomainBefore' | |
| 3178 | + | type MockHistoryRepository_HasEntriesByDomainBefore_Call struct { | |
| 3179 | + | *mock.Call | |
| 3180 | + | } | |
| 3181 | + | ||
| 3182 | + | // HasEntriesByDomainBefore is a helper method to define mock.On call | |
| 3183 | + | // - ctx context.Context | |
| 3184 | + | // - domain string | |
| 3185 | + | // - before time.Time | |
| 3186 | + | func (_e *MockHistoryRepository_Expecter) HasEntriesByDomainBefore(ctx interface{}, domain interface{}, before interface{}) *MockHistoryRepository_HasEntriesByDomainBefore_Call { | |
| 3187 | + | return &MockHistoryRepository_HasEntriesByDomainBefore_Call{Call: _e.mock.On("HasEntriesByDomainBefore", ctx, domain, before)} | |
| 3188 | + | } | |
| 3189 | + | ||
| 3190 | + | func (_c *MockHistoryRepository_HasEntriesByDomainBefore_Call) Run(run func(ctx context.Context, domain string, before time.Time)) *MockHistoryRepository_HasEntriesByDomainBefore_Call { | |
| 3191 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 3192 | + | var arg0 context.Context | |
| 3193 | + | if args[0] != nil { | |
| 3194 | + | arg0 = args[0].(context.Context) | |
| 3195 | + | } | |
| 3196 | + | var arg1 string | |
| 3197 | + | if args[1] != nil { | |
| 3198 | + | arg1 = args[1].(string) | |
| 3199 | + | } | |
| 3200 | + | var arg2 time.Time | |
| 3201 | + | if args[2] != nil { | |
| 3202 | + | arg2 = args[2].(time.Time) | |
| 3203 | + | } | |
| 3204 | + | run( | |
| 3205 | + | arg0, | |
| 3206 | + | arg1, | |
| 3207 | + | arg2, | |
| 3208 | + | ) | |
| 3209 | + | }) | |
| 3210 | + | return _c | |
| 3211 | + | } | |
| 3212 | + | ||
| 3213 | + | func (_c *MockHistoryRepository_HasEntriesByDomainBefore_Call) Return(b bool, err error) *MockHistoryRepository_HasEntriesByDomainBefore_Call { | |
| 3214 | + | _c.Call.Return(b, err) | |
| 3215 | + | return _c | |
| 3216 | + | } | |
| 3217 | + | ||
| 3218 | + | func (_c *MockHistoryRepository_HasEntriesByDomainBefore_Call) RunAndReturn(run func(ctx context.Context, domain string, before time.Time) (bool, error)) *MockHistoryRepository_HasEntriesByDomainBefore_Call { | |
| 3219 | + | _c.Call.Return(run) | |
| 3220 | + | return _c | |
| 3221 | + | } | |
| 3222 | + | ||
| 2931 | 3223 | // IncrementVisitCount provides a mock function for the type MockHistoryRepository | |
| 2932 | 3224 | func (_mock *MockHistoryRepository) IncrementVisitCount(ctx context.Context, url string) error { | |
| 2933 | 3225 | ret := _mock.Called(ctx, url) | |
A internal/infrastructure/cef/favicon_systemviews.go +15 -0
| @@ -0,0 +1,15 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/dumber/internal/application/port" | |
| 4 | + | ||
| 5 | + | var _ port.SystemviewFaviconServiceSetter = (*Engine)(nil) | |
| 6 | + | ||
| 7 | + | // SetSystemviewFaviconService exposes the local favicon cache to internal | |
| 8 | + | // systemview pages. The scheme handler only serves cached PNGs; it does not | |
| 9 | + | // fetch remote favicons on behalf of History rendering. | |
| 10 | + | func (e *Engine) SetSystemviewFaviconService(service port.FaviconService) { | |
| 11 | + | if e == nil || e.schemeHandler == nil { | |
| 12 | + | return | |
| 13 | + | } | |
| 14 | + | e.schemeHandler.setFaviconService(service) | |
| 15 | + | } | |
M internal/infrastructure/cef/scheme_handler.go +251 -4
| @@ -13,7 +13,9 @@ import ( | |||
| 13 | 13 | "net" | |
| 14 | 14 | "net/http" | |
| 15 | 15 | "net/url" | |
| 16 | + | "os" | |
| 16 | 17 | "path" | |
| 18 | + | "strconv" | |
| 17 | 19 | "strings" | |
| 18 | 20 | "sync" | |
| 19 | 21 | "time" | |
| @@ -23,6 +25,7 @@ import ( | |||
| 23 | 25 | purecef "github.com/bnema/purego-cef/cef" | |
| 24 | 26 | ||
| 25 | 27 | "github.com/bnema/dumber/internal/application/port" | |
| 28 | + | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| 26 | 29 | "github.com/bnema/dumber/internal/infrastructure/netguard" | |
| 27 | 30 | "github.com/bnema/dumber/internal/infrastructure/webutil" | |
| 28 | 31 | "github.com/bnema/dumber/internal/logging" | |
| @@ -57,6 +60,7 @@ type dumbSchemeHandler struct { | |||
| 57 | 60 | ctx context.Context | |
| 58 | 61 | messageRouter *MessageRouter | |
| 59 | 62 | transcoder port.MediaTranscoder | |
| 63 | + | faviconService port.FaviconService | |
| 60 | 64 | assets embed.FS | |
| 61 | 65 | assetsSet bool | |
| 62 | 66 | assetDir string | |
| @@ -123,6 +127,12 @@ func (h *dumbSchemeHandler) setAssets(assets embed.FS) { | |||
| 123 | 127 | h.logger.Debug().Msg("assets filesystem configured") | |
| 124 | 128 | } | |
| 125 | 129 | ||
| 130 | + | func (h *dumbSchemeHandler) setFaviconService(service port.FaviconService) { | |
| 131 | + | h.mu.Lock() | |
| 132 | + | defer h.mu.Unlock() | |
| 133 | + | h.faviconService = service | |
| 134 | + | } | |
| 135 | + | ||
| 126 | 136 | // Create implements purecef.SchemeHandlerFactory. Called on the CEF IO thread | |
| 127 | 137 | // for requests to either dumb:// or the internal https origin. | |
| 128 | 138 | func (h *dumbSchemeHandler) Create(browser purecef.Browser, _ purecef.Frame, _ string, request purecef.Request) purecef.ResourceHandler { | |
| @@ -171,6 +181,12 @@ func (h *dumbSchemeHandler) Create(browser purecef.Browser, _ purecef.Frame, _ s | |||
| 171 | 181 | // handleAPI routes API requests to the message router or built-in handlers. | |
| 172 | 182 | func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path string, request purecef.Request) purecef.ResourceHandler { | |
| 173 | 183 | if strings.EqualFold(method, http.MethodOptions) { | |
| 184 | + | if path == "/api/config" || path == "/api/config/default" { | |
| 185 | + | if denied := h.rejectUntrustedConfigRequester(request); denied != nil { | |
| 186 | + | return denied | |
| 187 | + | } | |
| 188 | + | return h.newPrivateAPIRawResourceHandler(http.StatusNoContent, "text/plain; charset=utf-8", nil) | |
| 189 | + | } | |
| 174 | 190 | return h.newAPIRawResourceHandler(http.StatusNoContent, "text/plain; charset=utf-8", nil) | |
| 175 | 191 | } | |
| 176 | 192 | if isAPIGetMethod(method) { | |
| @@ -197,17 +213,19 @@ func isAPIPostMethod(method string) bool { | |||
| 197 | 213 | func (h *dumbSchemeHandler) handleAPIGet(path string, request purecef.Request) (purecef.ResourceHandler, bool) { | |
| 198 | 214 | switch path { | |
| 199 | 215 | case "/api/config": | |
| 200 | - | if denied := h.rejectForbiddenAPIOrigin(request); denied != nil { | |
| 216 | + | if denied := h.rejectUntrustedConfigRequester(request); denied != nil { | |
| 201 | 217 | return denied, true | |
| 202 | 218 | } | |
| 203 | 219 | return h.handleConfigAPI(h.currentConfigPayload), true | |
| 204 | 220 | case "/api/config/default": | |
| 205 | - | if denied := h.rejectForbiddenAPIOrigin(request); denied != nil { | |
| 221 | + | if denied := h.rejectUntrustedConfigRequester(request); denied != nil { | |
| 206 | 222 | return denied, true | |
| 207 | 223 | } | |
| 208 | 224 | return h.handleConfigAPI(h.defaultConfigPayload), true | |
| 209 | 225 | case "/api/transcode": | |
| 210 | 226 | return h.handleTranscodeAPI(request), true | |
| 227 | + | case "/api/favicon": | |
| 228 | + | return h.handleFaviconAPI(request), true | |
| 211 | 229 | default: | |
| 212 | 230 | return nil, false | |
| 213 | 231 | } | |
| @@ -259,6 +277,103 @@ func (h *dumbSchemeHandler) rejectForbiddenAPIOrigin(request purecef.Request) pu | |||
| 259 | 277 | return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden origin"}) | |
| 260 | 278 | } | |
| 261 | 279 | ||
| 280 | + | func (h *dumbSchemeHandler) rejectUntrustedConfigRequester(request purecef.Request) purecef.ResourceHandler { | |
| 281 | + | if request == nil { | |
| 282 | + | return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"}) | |
| 283 | + | } | |
| 284 | + | origin := strings.TrimSpace(request.GetHeaderByName("Origin")) | |
| 285 | + | if origin != "" { | |
| 286 | + | if isTrustedSystemviewURL(origin) { | |
| 287 | + | return nil | |
| 288 | + | } | |
| 289 | + | return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"}) | |
| 290 | + | } | |
| 291 | + | referrer := strings.TrimSpace(request.GetReferrerURL()) | |
| 292 | + | if referrer == "" { | |
| 293 | + | referrer = strings.TrimSpace(request.GetHeaderByName("Referer")) | |
| 294 | + | } | |
| 295 | + | if !isTrustedSystemviewURL(referrer) { | |
| 296 | + | return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"}) | |
| 297 | + | } | |
| 298 | + | return nil | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | const systemviewFaviconSize = 32 | |
| 302 | + | ||
| 303 | + | func (h *dumbSchemeHandler) handleFaviconAPI(request purecef.Request) purecef.ResourceHandler { | |
| 304 | + | if denied := h.rejectUntrustedFaviconRequester(request); denied != nil { | |
| 305 | + | return denied | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | h.mu.RLock() | |
| 309 | + | service := h.faviconService | |
| 310 | + | h.mu.RUnlock() | |
| 311 | + | if service == nil { | |
| 312 | + | return h.newPrivateAPIJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "favicon unavailable"}) | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | parsed, err := url.Parse(request.GetURL()) | |
| 316 | + | if err != nil { | |
| 317 | + | return h.newPrivateAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid request URL"}) | |
| 318 | + | } | |
| 319 | + | domain := domainurl.CanonicalDomain(parsed.Query().Get("domain")) | |
| 320 | + | if domain == "" { | |
| 321 | + | return h.newPrivateAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "missing domain"}) | |
| 322 | + | } | |
| 323 | + | size := systemviewFaviconSize | |
| 324 | + | if rawSize := strings.TrimSpace(parsed.Query().Get("size")); rawSize != "" { | |
| 325 | + | parsedSize, parseErr := strconv.Atoi(rawSize) | |
| 326 | + | if parseErr != nil || parsedSize != systemviewFaviconSize { | |
| 327 | + | return h.newPrivateAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "unsupported favicon size"}) | |
| 328 | + | } | |
| 329 | + | size = parsedSize | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | return newFaviconResourceHandler(service, domain, size) | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | func (h *dumbSchemeHandler) rejectUntrustedFaviconRequester(request purecef.Request) purecef.ResourceHandler { | |
| 336 | + | if request == nil { | |
| 337 | + | return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"}) | |
| 338 | + | } | |
| 339 | + | origin := strings.TrimSpace(request.GetHeaderByName("Origin")) | |
| 340 | + | if origin != "" { | |
| 341 | + | if isTrustedSystemviewURL(origin) { | |
| 342 | + | return nil | |
| 343 | + | } | |
| 344 | + | return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"}) | |
| 345 | + | } | |
| 346 | + | referrer := strings.TrimSpace(request.GetReferrerURL()) | |
| 347 | + | if referrer == "" { | |
| 348 | + | referrer = strings.TrimSpace(request.GetHeaderByName("Referer")) | |
| 349 | + | } | |
| 350 | + | if !isTrustedSystemviewURL(referrer) { | |
| 351 | + | return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"}) | |
| 352 | + | } | |
| 353 | + | return nil | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | func isTrustedSystemviewURL(raw string) bool { | |
| 357 | + | parsed, err := url.Parse(strings.TrimSpace(raw)) | |
| 358 | + | if err != nil || parsed.Scheme == "" { | |
| 359 | + | return false | |
| 360 | + | } | |
| 361 | + | if strings.EqualFold(parsed.Scheme, actualInternalScheme) { | |
| 362 | + | return strings.EqualFold(parsed.Host, actualInternalHost) | |
| 363 | + | } | |
| 364 | + | if !strings.EqualFold(parsed.Scheme, "dumb") { | |
| 365 | + | return false | |
| 366 | + | } | |
| 367 | + | host := parsed.Host | |
| 368 | + | if host == "" { | |
| 369 | + | host = parsed.Opaque | |
| 370 | + | } | |
| 371 | + | if idx := strings.IndexAny(host, "/?#"); idx >= 0 { | |
| 372 | + | host = host[:idx] | |
| 373 | + | } | |
| 374 | + | return isInternalPageHost(host) | |
| 375 | + | } | |
| 376 | + | ||
| 262 | 377 | func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.ResourceHandler { | |
| 263 | 378 | if h.transcoder == nil { | |
| 264 | 379 | return h.newAPIJSONResourceHandler(http.StatusServiceUnavailable, map[string]string{"error": "transcoder unavailable"}) | |
| @@ -357,9 +472,9 @@ func resolveConfigPayload(build func() ([]byte, error)) ([]byte, error) { | |||
| 357 | 472 | func (h *dumbSchemeHandler) handleConfigAPI(build func() ([]byte, error)) purecef.ResourceHandler { | |
| 358 | 473 | data, err := resolveConfigPayload(build) | |
| 359 | 474 | if err != nil { | |
| 360 | - | return h.newAPIJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()}) | |
| 475 | + | return h.newPrivateAPIJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()}) | |
| 361 | 476 | } | |
| 362 | - | return h.newAPIRawResourceHandler(http.StatusOK, "application/json", data) | |
| 477 | + | return h.newPrivateAPIRawResourceHandler(http.StatusOK, "application/json", data) | |
| 363 | 478 | } | |
| 364 | 479 | ||
| 365 | 480 | // handleClipboardSet receives copied text from JS copy/cut events and writes | |
| @@ -789,6 +904,10 @@ func (h *dumbSchemeHandler) newAPIRawResourceHandler(status int, contentType str | |||
| 789 | 904 | return newStaticResourceHandler(status, contentType, data, apiResponseHeaders(nil)) | |
| 790 | 905 | } | |
| 791 | 906 | ||
| 907 | + | func (h *dumbSchemeHandler) newPrivateAPIRawResourceHandler(status int, contentType string, data []byte) purecef.ResourceHandler { | |
| 908 | + | return newStaticResourceHandler(status, contentType, data, map[string]string{"Cache-Control": "no-store"}) | |
| 909 | + | } | |
| 910 | + | ||
| 792 | 911 | func (h *dumbSchemeHandler) newErrorResourceHandler(status int, msg string) purecef.ResourceHandler { | |
| 793 | 912 | escaped := html.EscapeString(msg) | |
| 794 | 913 | body := fmt.Sprintf(`<!DOCTYPE html><html><body><h1>%d</h1><p>%s</p></body></html>`, status, escaped) | |
| @@ -804,6 +923,134 @@ func (h *dumbSchemeHandler) newAPIJSONResourceHandler(status int, v any) purecef | |||
| 804 | 923 | return h.newAPIRawResourceHandler(status, "application/json", data) | |
| 805 | 924 | } | |
| 806 | 925 | ||
| 926 | + | func (h *dumbSchemeHandler) newPrivateAPIJSONResourceHandler(status int, v any) purecef.ResourceHandler { | |
| 927 | + | data, err := json.Marshal(v) | |
| 928 | + | if err != nil { | |
| 929 | + | fallback, _ := json.Marshal(map[string]string{"error": "JSON encoding failed"}) | |
| 930 | + | return h.newPrivateAPIRawResourceHandler(http.StatusInternalServerError, "application/json", fallback) | |
| 931 | + | } | |
| 932 | + | return h.newPrivateAPIRawResourceHandler(status, "application/json", data) | |
| 933 | + | } | |
| 934 | + | ||
| 935 | + | func newFaviconResourceHandler(service port.FaviconService, domain string, size int) purecef.ResourceHandler { | |
| 936 | + | return cefNewResourceHandler(&faviconResourceHandler{ | |
| 937 | + | service: service, | |
| 938 | + | domain: domain, | |
| 939 | + | size: size, | |
| 940 | + | done: make(chan struct{}), | |
| 941 | + | headers: map[string]string{"Cache-Control": "no-store"}, | |
| 942 | + | }) | |
| 943 | + | } | |
| 944 | + | ||
| 945 | + | type faviconResourceHandler struct { | |
| 946 | + | service port.FaviconService | |
| 947 | + | domain string | |
| 948 | + | size int | |
| 949 | + | headers map[string]string | |
| 950 | + | once sync.Once | |
| 951 | + | done chan struct{} | |
| 952 | + | data []byte | |
| 953 | + | contentType string | |
| 954 | + | statusCode int | |
| 955 | + | offset int | |
| 956 | + | } | |
| 957 | + | ||
| 958 | + | func (rh *faviconResourceHandler) load() { | |
| 959 | + | defer close(rh.done) | |
| 960 | + | rh.statusCode = http.StatusNotFound | |
| 961 | + | rh.contentType = "application/json" | |
| 962 | + | rh.data = []byte(`{"error":"favicon not cached"}`) | |
| 963 | + | if rh.service == nil || !rh.service.HasPNGSizedOnDisk(rh.domain, rh.size) { | |
| 964 | + | return | |
| 965 | + | } | |
| 966 | + | path := rh.service.DiskPathPNGSized(rh.domain, rh.size) | |
| 967 | + | if path == "" { | |
| 968 | + | return | |
| 969 | + | } | |
| 970 | + | data, err := os.ReadFile(path) | |
| 971 | + | if err != nil || len(data) == 0 { | |
| 972 | + | return | |
| 973 | + | } | |
| 974 | + | rh.statusCode = http.StatusOK | |
| 975 | + | rh.contentType = "image/png" | |
| 976 | + | rh.data = data | |
| 977 | + | } | |
| 978 | + | ||
| 979 | + | func (rh *faviconResourceHandler) Open(_ purecef.Request, handleRequest *int32, callback purecef.Callback) int32 { | |
| 980 | + | if handleRequest != nil { | |
| 981 | + | *handleRequest = 0 | |
| 982 | + | } | |
| 983 | + | rh.once.Do(func() { | |
| 984 | + | go func() { | |
| 985 | + | rh.load() | |
| 986 | + | if callback != nil { | |
| 987 | + | callback.Cont() | |
| 988 | + | } | |
| 989 | + | }() | |
| 990 | + | }) | |
| 991 | + | return 1 | |
| 992 | + | } | |
| 993 | + | ||
| 994 | + | func (rh *faviconResourceHandler) ProcessRequest(_ purecef.Request, callback purecef.Callback) int32 { | |
| 995 | + | rh.once.Do(func() { | |
| 996 | + | go func() { | |
| 997 | + | rh.load() | |
| 998 | + | if callback != nil { | |
| 999 | + | callback.Cont() | |
| 1000 | + | } | |
| 1001 | + | }() | |
| 1002 | + | }) | |
| 1003 | + | return 1 | |
| 1004 | + | } | |
| 1005 | + | ||
| 1006 | + | func (rh *faviconResourceHandler) GetResponseHeaders(response purecef.Response, responseLength *int64, _ uintptr) { | |
| 1007 | + | <-rh.done | |
| 1008 | + | response.SetStatus(int32(rh.statusCode)) | |
| 1009 | + | if text := http.StatusText(rh.statusCode); text != "" { | |
| 1010 | + | response.SetStatusText(text) | |
| 1011 | + | } | |
| 1012 | + | mimeType, charset := splitMimeCharset(rh.contentType) | |
| 1013 | + | response.SetMimeType(mimeType) | |
| 1014 | + | if charset != "" { | |
| 1015 | + | response.SetCharset(charset) | |
| 1016 | + | } | |
| 1017 | + | for name, value := range rh.headers { | |
| 1018 | + | response.SetHeaderByName(name, value, 1) | |
| 1019 | + | } | |
| 1020 | + | if responseLength != nil { | |
| 1021 | + | *responseLength = int64(len(rh.data)) | |
| 1022 | + | } | |
| 1023 | + | } | |
| 1024 | + | ||
| 1025 | + | func (rh *faviconResourceHandler) Skip(_ int64, _ *int64, _ purecef.ResourceSkipCallback) int32 { | |
| 1026 | + | return 0 | |
| 1027 | + | } | |
| 1028 | + | ||
| 1029 | + | func (rh *faviconResourceHandler) Read(dataOut unsafe.Pointer, bytesToRead int32, bytesRead *int32, _ purecef.ResourceReadCallback) int32 { | |
| 1030 | + | <-rh.done | |
| 1031 | + | if rh.offset >= len(rh.data) { | |
| 1032 | + | return 0 | |
| 1033 | + | } | |
| 1034 | + | remaining := len(rh.data) - rh.offset | |
| 1035 | + | toRead := int(bytesToRead) | |
| 1036 | + | if toRead > remaining { | |
| 1037 | + | toRead = remaining | |
| 1038 | + | } | |
| 1039 | + | dst := unsafe.Slice((*byte)(dataOut), toRead) | |
| 1040 | + | copy(dst, rh.data[rh.offset:rh.offset+toRead]) | |
| 1041 | + | rh.offset += toRead | |
| 1042 | + | if bytesRead != nil { | |
| 1043 | + | *bytesRead = int32(toRead) | |
| 1044 | + | } | |
| 1045 | + | return 1 | |
| 1046 | + | } | |
| 1047 | + | ||
| 1048 | + | func (rh *faviconResourceHandler) ReadResponse(_ unsafe.Pointer, _ int32, _ *int32, _ purecef.Callback) int32 { | |
| 1049 | + | return 0 | |
| 1050 | + | } | |
| 1051 | + | ||
| 1052 | + | func (rh *faviconResourceHandler) Cancel() {} | |
| 1053 | + | ||
| 807 | 1054 | // Open handles the request immediately (synchronous). | |
| 808 | 1055 | func (rh *staticResourceHandler) Open(_ purecef.Request, handleRequest *int32, _ purecef.Callback) int32 { | |
| 809 | 1056 | // Set handleRequest = true (1) to indicate we handle it immediately. | |
M internal/infrastructure/cef/scheme_handler_test.go +187 -0
| @@ -26,6 +26,31 @@ func expectAPIResponseHeaders(response *cefmocks.MockResponse) { | |||
| 26 | 26 | response.EXPECT().SetHeaderByName("Cache-Control", "no-store", int32(1)).Once() | |
| 27 | 27 | } | |
| 28 | 28 | ||
| 29 | + | func expectPrivateAPIResponseHeaders(response *cefmocks.MockResponse) { | |
| 30 | + | response.EXPECT().SetHeaderByName("Cache-Control", "no-store", int32(1)).Once() | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | type panicFaviconService struct{} | |
| 34 | + | ||
| 35 | + | func (panicFaviconService) GetCached(context.Context, string) ([]byte, bool) { | |
| 36 | + | panic("unexpected favicon lookup") | |
| 37 | + | } | |
| 38 | + | func (panicFaviconService) Get(context.Context, string) ([]byte, error) { | |
| 39 | + | panic("unexpected favicon lookup") | |
| 40 | + | } | |
| 41 | + | func (panicFaviconService) DiskPathPNG(string) string { panic("unexpected favicon lookup") } | |
| 42 | + | func (panicFaviconService) HasPNGOnDisk(string) bool { panic("unexpected favicon lookup") } | |
| 43 | + | func (panicFaviconService) HasPNGSizedOnDisk(string, int) bool { panic("unexpected favicon lookup") } | |
| 44 | + | func (panicFaviconService) DiskPathPNGSized(string, int) string { panic("unexpected favicon lookup") } | |
| 45 | + | func (panicFaviconService) EnsureSizedPNG(context.Context, string, int) error { | |
| 46 | + | panic("unexpected favicon lookup") | |
| 47 | + | } | |
| 48 | + | func (panicFaviconService) EnsureCacheDir() error { panic("unexpected favicon lookup") } | |
| 49 | + | func (panicFaviconService) EnsureDiskCache(context.Context, string) { | |
| 50 | + | panic("unexpected favicon lookup") | |
| 51 | + | } | |
| 52 | + | func (panicFaviconService) Close() {} | |
| 53 | + | ||
| 29 | 54 | func TestResolveConfigPayload_UsesInjectedBuilder(t *testing.T) { | |
| 30 | 55 | data, err := resolveConfigPayload(func() ([]byte, error) { | |
| 31 | 56 | return []byte(`{"engine_type":"cef"}`), nil | |
| @@ -96,6 +121,168 @@ func TestRejectForbiddenAPIOrigin_RejectsExternalOrigin(t *testing.T) { | |||
| 96 | 121 | require.Positive(t, responseLength) | |
| 97 | 122 | } | |
| 98 | 123 | ||
| 124 | + | func TestRejectUntrustedConfigRequesterRequiresTrustedOriginOrReferrer(t *testing.T) { | |
| 125 | + | oldNewResourceHandler := cefNewResourceHandler | |
| 126 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 127 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |
| 128 | + | ||
| 129 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 130 | + | require.NoError(t, err) | |
| 131 | + | ||
| 132 | + | trustedOrigin := cefmocks.NewMockRequest(t) | |
| 133 | + | trustedOrigin.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once() | |
| 134 | + | require.Nil(t, h.rejectUntrustedConfigRequester(trustedOrigin)) | |
| 135 | + | ||
| 136 | + | trustedReferrer := cefmocks.NewMockRequest(t) | |
| 137 | + | trustedReferrer.EXPECT().GetHeaderByName("Origin").Return("").Once() | |
| 138 | + | trustedReferrer.EXPECT().GetReferrerURL().Return("dumb://history").Once() | |
| 139 | + | require.Nil(t, h.rejectUntrustedConfigRequester(trustedReferrer)) | |
| 140 | + | ||
| 141 | + | emptyContext := cefmocks.NewMockRequest(t) | |
| 142 | + | emptyContext.EXPECT().GetHeaderByName("Origin").Return("").Once() | |
| 143 | + | emptyContext.EXPECT().GetReferrerURL().Return("").Once() | |
| 144 | + | emptyContext.EXPECT().GetHeaderByName("Referer").Return("").Once() | |
| 145 | + | denied := h.rejectUntrustedConfigRequester(emptyContext) | |
| 146 | + | require.NotNil(t, denied) | |
| 147 | + | ||
| 148 | + | response := cefmocks.NewMockResponse(t) | |
| 149 | + | response.EXPECT().SetStatus(int32(http.StatusForbidden)).Once() | |
| 150 | + | response.EXPECT().SetStatusText(http.StatusText(http.StatusForbidden)).Once() | |
| 151 | + | response.EXPECT().SetMimeType("application/json").Once() | |
| 152 | + | expectPrivateAPIResponseHeaders(response) | |
| 153 | + | var responseLength int64 | |
| 154 | + | denied.GetResponseHeaders(response, &responseLength, 0) | |
| 155 | + | require.Positive(t, responseLength) | |
| 156 | + | ||
| 157 | + | untrustedOrigin := cefmocks.NewMockRequest(t) | |
| 158 | + | untrustedOrigin.EXPECT().GetHeaderByName("Origin").Return("https://evil.example").Once() | |
| 159 | + | require.NotNil(t, h.rejectUntrustedConfigRequester(untrustedOrigin)) | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func TestHandleConfigAPIUsesPrivateNoCORSHeaders(t *testing.T) { | |
| 163 | + | oldNewResourceHandler := cefNewResourceHandler | |
| 164 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 165 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |
| 166 | + | ||
| 167 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{"ok":true}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 168 | + | require.NoError(t, err) | |
| 169 | + | ||
| 170 | + | response := cefmocks.NewMockResponse(t) | |
| 171 | + | response.EXPECT().SetStatus(int32(http.StatusOK)).Once() | |
| 172 | + | response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once() | |
| 173 | + | response.EXPECT().SetMimeType("application/json").Once() | |
| 174 | + | expectPrivateAPIResponseHeaders(response) | |
| 175 | + | ||
| 176 | + | handler := h.handleConfigAPI(h.currentConfigPayload) | |
| 177 | + | var responseLength int64 | |
| 178 | + | handler.GetResponseHeaders(response, &responseLength, 0) | |
| 179 | + | require.Positive(t, responseLength) | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func TestHandleConfigAPIOptionsUsesPrivateNoCORSHeaders(t *testing.T) { | |
| 183 | + | oldNewResourceHandler := cefNewResourceHandler | |
| 184 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 185 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |
| 186 | + | ||
| 187 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 188 | + | require.NoError(t, err) | |
| 189 | + | ||
| 190 | + | request := cefmocks.NewMockRequest(t) | |
| 191 | + | request.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once() | |
| 192 | + | ||
| 193 | + | response := cefmocks.NewMockResponse(t) | |
| 194 | + | response.EXPECT().SetStatus(int32(http.StatusNoContent)).Once() | |
| 195 | + | response.EXPECT().SetStatusText(http.StatusText(http.StatusNoContent)).Once() | |
| 196 | + | response.EXPECT().SetMimeType("text/plain").Once() | |
| 197 | + | response.EXPECT().SetCharset("utf-8").Once() | |
| 198 | + | expectPrivateAPIResponseHeaders(response) | |
| 199 | + | ||
| 200 | + | handler := h.handleAPI(nil, http.MethodOptions, "/api/config", request) | |
| 201 | + | var responseLength int64 | |
| 202 | + | handler.GetResponseHeaders(response, &responseLength, 0) | |
| 203 | + | require.Zero(t, responseLength) | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | func TestIsTrustedSystemviewURL(t *testing.T) { | |
| 207 | + | t.Parallel() | |
| 208 | + | ||
| 209 | + | for _, raw := range []string{ | |
| 210 | + | "https://dumber.invalid/history", | |
| 211 | + | "dumb://history", | |
| 212 | + | "dumb:history", | |
| 213 | + | } { | |
| 214 | + | require.True(t, isTrustedSystemviewURL(raw), raw) | |
| 215 | + | } | |
| 216 | + | for _, raw := range []string{ | |
| 217 | + | "", | |
| 218 | + | "https://evil.example/history", | |
| 219 | + | "dumb://api/favicon", | |
| 220 | + | "dumb://evil/history", | |
| 221 | + | } { | |
| 222 | + | require.False(t, isTrustedSystemviewURL(raw), raw) | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | func TestRejectUntrustedFaviconRequesterAllowsTrustedOriginWithoutReferrer(t *testing.T) { | |
| 227 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 228 | + | require.NoError(t, err) | |
| 229 | + | ||
| 230 | + | request := cefmocks.NewMockRequest(t) | |
| 231 | + | request.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once() | |
| 232 | + | ||
| 233 | + | require.Nil(t, h.rejectUntrustedFaviconRequester(request)) | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | func TestRejectUntrustedFaviconRequesterRejectsUntrustedOriginEvenWithTrustedReferrer(t *testing.T) { | |
| 237 | + | oldNewResourceHandler := cefNewResourceHandler | |
| 238 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 239 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |
| 240 | + | ||
| 241 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 242 | + | require.NoError(t, err) | |
| 243 | + | ||
| 244 | + | request := cefmocks.NewMockRequest(t) | |
| 245 | + | request.EXPECT().GetHeaderByName("Origin").Return("https://evil.example").Once() | |
| 246 | + | ||
| 247 | + | require.NotNil(t, h.rejectUntrustedFaviconRequester(request)) | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | func TestRejectUntrustedFaviconRequesterRequiresTrustedReferrerWhenOriginAbsent(t *testing.T) { | |
| 251 | + | oldNewResourceHandler := cefNewResourceHandler | |
| 252 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 253 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |
| 254 | + | ||
| 255 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 256 | + | require.NoError(t, err) | |
| 257 | + | ||
| 258 | + | trusted := cefmocks.NewMockRequest(t) | |
| 259 | + | trusted.EXPECT().GetHeaderByName("Origin").Return("").Once() | |
| 260 | + | trusted.EXPECT().GetReferrerURL().Return("dumb://history").Once() | |
| 261 | + | require.Nil(t, h.rejectUntrustedFaviconRequester(trusted)) | |
| 262 | + | ||
| 263 | + | untrusted := cefmocks.NewMockRequest(t) | |
| 264 | + | untrusted.EXPECT().GetHeaderByName("Origin").Return("").Once() | |
| 265 | + | untrusted.EXPECT().GetReferrerURL().Return("").Once() | |
| 266 | + | untrusted.EXPECT().GetHeaderByName("Referer").Return("").Once() | |
| 267 | + | require.NotNil(t, h.rejectUntrustedFaviconRequester(untrusted)) | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | func TestHandleFaviconAPIDefersFaviconDiskChecksUntilResourceOpen(t *testing.T) { | |
| 271 | + | oldNewResourceHandler := cefNewResourceHandler | |
| 272 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 273 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |
| 274 | + | ||
| 275 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 276 | + | require.NoError(t, err) | |
| 277 | + | h.setFaviconService(panicFaviconService{}) | |
| 278 | + | ||
| 279 | + | request := cefmocks.NewMockRequest(t) | |
| 280 | + | request.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once() | |
| 281 | + | request.EXPECT().GetURL().Return(actualInternalOrigin + "/api/favicon?domain=example.com&size=32").Once() | |
| 282 | + | ||
| 283 | + | require.NotNil(t, h.handleFaviconAPI(request)) | |
| 284 | + | } | |
| 285 | + | ||
| 99 | 286 | func TestValidateTranscodeSourceURL_RejectsPrivateHosts(t *testing.T) { | |
| 100 | 287 | privateURL, err := url.Parse("http://127.0.0.1/video.mp4") | |
| 101 | 288 | require.NoError(t, err) | |
M internal/infrastructure/handlers/homepage/history.go +72 -0
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "encoding/json" | |
| 6 | 6 | "fmt" | |
| 7 | 7 | "strings" | |
| 8 | + | "time" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/dumber/internal/application/port" | |
| 10 | 11 | "github.com/bnema/dumber/internal/domain/entity" | |
| @@ -35,6 +36,12 @@ type timelineByDomainRequest struct { | |||
| 35 | 36 | Offset int `json:"offset"` | |
| 36 | 37 | } | |
| 37 | 38 | ||
| 39 | + | type timelineWindowRequest struct { | |
| 40 | + | RequestID string `json:"requestId"` | |
| 41 | + | Before string `json:"before"` | |
| 42 | + | Domain string `json:"domain"` | |
| 43 | + | } | |
| 44 | + | ||
| 38 | 45 | // HandleTimeline handles history_timeline messages. | |
| 39 | 46 | func (h *HistoryHandlers) HandleTimeline() port.WebUIMessageHandler { | |
| 40 | 47 | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |
| @@ -50,6 +57,9 @@ func (h *HistoryHandlers) HandleTimeline() port.WebUIMessageHandler { | |||
| 50 | 57 | Int("limit", req.Limit). | |
| 51 | 58 | Int("offset", req.Offset). | |
| 52 | 59 | Msg("handling history_timeline") | |
| 60 | + | if req.Limit <= 0 { | |
| 61 | + | return NewErrorResponse(req.RequestID, fmt.Errorf("history_timeline requires a positive limit; use history_timeline_window for lazy history loading")), nil | |
| 62 | + | } | |
| 53 | 63 | ||
| 54 | 64 | entries, err := h.historyUC.GetRecent(ctx, req.Limit, req.Offset) | |
| 55 | 65 | if err != nil { | |
| @@ -81,6 +91,9 @@ func (h *HistoryHandlers) HandleTimelineByDomain() port.WebUIMessageHandler { | |||
| 81 | 91 | Int("limit", req.Limit). | |
| 82 | 92 | Int("offset", req.Offset). | |
| 83 | 93 | Msg("handling history_timeline_by_domain") | |
| 94 | + | if req.Limit <= 0 { | |
| 95 | + | return NewErrorResponse(req.RequestID, fmt.Errorf("history_timeline_by_domain requires a positive limit; use history_timeline_window for lazy history loading")), nil | |
| 96 | + | } | |
| 84 | 97 | ||
| 85 | 98 | entries, err := h.historyUC.GetRecentByDomain(ctx, req.Domain, req.Limit, req.Offset) | |
| 86 | 99 | if err != nil { | |
| @@ -91,6 +104,42 @@ func (h *HistoryHandlers) HandleTimelineByDomain() port.WebUIMessageHandler { | |||
| 91 | 104 | }) | |
| 92 | 105 | } | |
| 93 | 106 | ||
| 107 | + | // HandleTimelineWindow handles history_timeline_window messages. | |
| 108 | + | func (h *HistoryHandlers) HandleTimelineWindow() port.WebUIMessageHandler { | |
| 109 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |
| 110 | + | log := logging.FromContext(ctx) | |
| 111 | + | ||
| 112 | + | var req timelineWindowRequest | |
| 113 | + | if err := json.Unmarshal(payload, &req); err != nil { | |
| 114 | + | return NewErrorResponse("", err), nil | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | domain := strings.TrimSpace(req.Domain) | |
| 118 | + | ||
| 119 | + | var before time.Time | |
| 120 | + | if strings.TrimSpace(req.Before) != "" { | |
| 121 | + | parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(req.Before)) | |
| 122 | + | if err != nil { | |
| 123 | + | return NewErrorResponse(req.RequestID, fmt.Errorf("invalid history window cursor")), nil | |
| 124 | + | } | |
| 125 | + | before = parsed | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | log.Debug(). | |
| 129 | + | Str("request_id", req.RequestID). | |
| 130 | + | Str("domain", domain). | |
| 131 | + | Time("before", before). | |
| 132 | + | Msg("handling history_timeline_window") | |
| 133 | + | ||
| 134 | + | window, err := h.historyUC.GetRecentWindow(ctx, before, domain) | |
| 135 | + | if err != nil { | |
| 136 | + | return NewErrorResponse(req.RequestID, err), nil | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | return NewSuccessResponse(req.RequestID, window), nil | |
| 140 | + | }) | |
| 141 | + | } | |
| 142 | + | ||
| 94 | 143 | // searchRequest is the payload for history_search_fts messages. | |
| 95 | 144 | type searchRequest struct { | |
| 96 | 145 | RequestID string `json:"requestId"` | |
| @@ -209,6 +258,29 @@ func (h *HistoryHandlers) HandleClearAll() port.WebUIMessageHandler { | |||
| 209 | 258 | }) | |
| 210 | 259 | } | |
| 211 | 260 | ||
| 261 | + | // HandleStats handles history_stats messages. | |
| 262 | + | func (h *HistoryHandlers) HandleStats() port.WebUIMessageHandler { | |
| 263 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |
| 264 | + | log := logging.FromContext(ctx) | |
| 265 | + | ||
| 266 | + | var req struct { | |
| 267 | + | RequestID string `json:"requestId"` | |
| 268 | + | } | |
| 269 | + | if err := json.Unmarshal(payload, &req); err != nil { | |
| 270 | + | return NewErrorResponse("", err), nil | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | log.Debug().Str("request_id", req.RequestID).Msg("handling history_stats") | |
| 274 | + | ||
| 275 | + | stats, err := h.historyUC.GetStats(ctx) | |
| 276 | + | if err != nil { | |
| 277 | + | return NewErrorResponse(req.RequestID, err), nil | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | return NewSuccessResponse(req.RequestID, stats), nil | |
| 281 | + | }) | |
| 282 | + | } | |
| 283 | + | ||
| 212 | 284 | // HandleAnalytics handles history_analytics messages. | |
| 213 | 285 | func (h *HistoryHandlers) HandleAnalytics() port.WebUIMessageHandler { | |
| 214 | 286 | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |
M internal/infrastructure/handlers/homepage/registry.go +2 -0
| @@ -30,10 +30,12 @@ func RegisterHandlers(ctx context.Context, router port.WebUIHandlerRouter, cfg C | |||
| 30 | 30 | historyHandlers := NewHistoryHandlers(cfg.HistoryUC) | |
| 31 | 31 | handlers["history_timeline"] = historyHandlers.HandleTimeline() | |
| 32 | 32 | handlers["history_timeline_by_domain"] = historyHandlers.HandleTimelineByDomain() | |
| 33 | + | handlers["history_timeline_window"] = historyHandlers.HandleTimelineWindow() | |
| 33 | 34 | handlers["history_search_fts"] = historyHandlers.HandleSearchFTS() | |
| 34 | 35 | handlers["history_delete_entry"] = historyHandlers.HandleDeleteEntry() | |
| 35 | 36 | handlers["history_delete_range"] = historyHandlers.HandleDeleteRange() | |
| 36 | 37 | handlers["history_clear_all"] = historyHandlers.HandleClearAll() | |
| 38 | + | handlers["history_stats"] = historyHandlers.HandleStats() | |
| 37 | 39 | handlers["history_analytics"] = historyHandlers.HandleAnalytics() | |
| 38 | 40 | handlers["history_domain_stats"] = historyHandlers.HandleDomainStats() | |
| 39 | 41 | handlers["history_delete_domain"] = historyHandlers.HandleDeleteDomain() | |
M internal/infrastructure/persistence/sqlite/history_repo.go +66 -0
| @@ -483,6 +483,13 @@ func isExpectedFTSError(err error) bool { | |||
| 483 | 483 | } | |
| 484 | 484 | ||
| 485 | 485 | func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { | |
| 486 | + | if limit <= 0 { | |
| 487 | + | rows, err := r.queries.GetAllRecentHistory(ctx) | |
| 488 | + | if err != nil { | |
| 489 | + | return nil, err | |
| 490 | + | } | |
| 491 | + | return historyEntriesFromRows(rows), nil | |
| 492 | + | } | |
| 486 | 493 | rows, err := r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{ | |
| 487 | 494 | Limit: int64(limit), | |
| 488 | 495 | Offset: int64(offset), | |
| @@ -499,6 +506,13 @@ func (r *historyRepo) GetRecentByDomain(ctx context.Context, domain string, limi | |||
| 499 | 506 | if domain == "" { | |
| 500 | 507 | return nil, fmt.Errorf("history domain is required: %q", rawDomain) | |
| 501 | 508 | } | |
| 509 | + | if limit <= 0 { | |
| 510 | + | rows, err := r.queries.GetAllRecentHistoryByDomain(ctx, sql.NullString{String: domain, Valid: true}) | |
| 511 | + | if err != nil { | |
| 512 | + | return nil, err | |
| 513 | + | } | |
| 514 | + | return historyEntriesFromRows(rows), nil | |
| 515 | + | } | |
| 502 | 516 | rows, err := r.queries.GetRecentHistoryByDomain(ctx, sqlc.GetRecentHistoryByDomainParams{ | |
| 503 | 517 | Domain: sql.NullString{String: domain, Valid: true}, | |
| 504 | 518 | Limit: int64(limit), | |
| @@ -510,6 +524,58 @@ func (r *historyRepo) GetRecentByDomain(ctx context.Context, domain string, limi | |||
| 510 | 524 | return historyEntriesFromRows(rows), nil | |
| 511 | 525 | } | |
| 512 | 526 | ||
| 527 | + | func (r *historyRepo) GetRecentWindow(ctx context.Context, before, after time.Time) ([]*entity.HistoryEntry, error) { | |
| 528 | + | rows, err := r.queries.GetRecentHistoryWindow(ctx, sqlc.GetRecentHistoryWindowParams{ | |
| 529 | + | Before: sql.NullTime{Time: before, Valid: true}, | |
| 530 | + | After: sql.NullTime{Time: after, Valid: true}, | |
| 531 | + | }) | |
| 532 | + | if err != nil { | |
| 533 | + | return nil, err | |
| 534 | + | } | |
| 535 | + | return historyEntriesFromRows(rows), nil | |
| 536 | + | } | |
| 537 | + | ||
| 538 | + | func (r *historyRepo) GetRecentWindowByDomain(ctx context.Context, domain string, before, after time.Time) ([]*entity.HistoryEntry, error) { | |
| 539 | + | rawDomain := domain | |
| 540 | + | domain = domainurl.CanonicalDomain(domain) | |
| 541 | + | if domain == "" { | |
| 542 | + | return nil, fmt.Errorf("history domain is required: %q", rawDomain) | |
| 543 | + | } | |
| 544 | + | rows, err := r.queries.GetRecentHistoryWindowByDomain(ctx, sqlc.GetRecentHistoryWindowByDomainParams{ | |
| 545 | + | Domain: sql.NullString{String: domain, Valid: true}, | |
| 546 | + | Before: sql.NullTime{Time: before, Valid: true}, | |
| 547 | + | After: sql.NullTime{Time: after, Valid: true}, | |
| 548 | + | }) | |
| 549 | + | if err != nil { | |
| 550 | + | return nil, err | |
| 551 | + | } | |
| 552 | + | return historyEntriesFromRows(rows), nil | |
| 553 | + | } | |
| 554 | + | ||
| 555 | + | func (r *historyRepo) HasEntriesBefore(ctx context.Context, before time.Time) (bool, error) { | |
| 556 | + | value, err := r.queries.HasHistoryBefore(ctx, sql.NullTime{Time: before, Valid: true}) | |
| 557 | + | if err != nil { | |
| 558 | + | return false, err | |
| 559 | + | } | |
| 560 | + | return value != 0, nil | |
| 561 | + | } | |
| 562 | + | ||
| 563 | + | func (r *historyRepo) HasEntriesByDomainBefore(ctx context.Context, domain string, before time.Time) (bool, error) { | |
| 564 | + | rawDomain := domain | |
| 565 | + | domain = domainurl.CanonicalDomain(domain) | |
| 566 | + | if domain == "" { | |
| 567 | + | return false, fmt.Errorf("history domain is required: %q", rawDomain) | |
| 568 | + | } | |
| 569 | + | value, err := r.queries.HasHistoryByDomainBefore(ctx, sqlc.HasHistoryByDomainBeforeParams{ | |
| 570 | + | Domain: sql.NullString{String: domain, Valid: true}, | |
| 571 | + | Before: sql.NullTime{Time: before, Valid: true}, | |
| 572 | + | }) | |
| 573 | + | if err != nil { | |
| 574 | + | return false, err | |
| 575 | + | } | |
| 576 | + | return value != 0, nil | |
| 577 | + | } | |
| 578 | + | ||
| 513 | 579 | func (r *historyRepo) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | |
| 514 | 580 | if days <= 0 { | |
| 515 | 581 | return nil, fmt.Errorf("days must be positive, got %d", days) | |
M internal/infrastructure/persistence/sqlite/history_repo_test.go +5 -1
| @@ -191,6 +191,10 @@ func TestHistoryRepository_CRUD(t *testing.T) { | |||
| 191 | 191 | require.NoError(t, err) | |
| 192 | 192 | require.Len(t, recent, 1) | |
| 193 | 193 | ||
| 194 | + | allRecent, err := repo.GetRecent(ctx, 0, 0) | |
| 195 | + | require.NoError(t, err) | |
| 196 | + | require.Len(t, allRecent, 1) | |
| 197 | + | ||
| 194 | 198 | // Delete | |
| 195 | 199 | require.NoError(t, repo.Delete(ctx, found.ID)) | |
| 196 | 200 | ||
| @@ -862,7 +866,7 @@ func TestHistoryRepository_DomainColumnPowersFilteringStatsAndDelete(t *testing. | |||
| 862 | 866 | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{URL: "https://example.com/b", Title: "Example B"})) | |
| 863 | 867 | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{URL: "https://other.test", Title: "Other"})) | |
| 864 | 868 | ||
| 865 | - | exampleEntries, err := repo.GetRecentByDomain(ctx, "www.example.com", 10, 0) | |
| 869 | + | exampleEntries, err := repo.GetRecentByDomain(ctx, "www.example.com", 0, 0) | |
| 866 | 870 | require.NoError(t, err) | |
| 867 | 871 | require.Len(t, exampleEntries, 2) | |
| 868 | 872 | for _, entry := range exampleEntries { | |
M internal/infrastructure/persistence/sqlite/lazy_repos.go +28 -0
| @@ -156,6 +156,34 @@ func (r *LazyHistoryRepository) GetRecentByDomain(ctx context.Context, domain st | |||
| 156 | 156 | return r.repo.GetRecentByDomain(ctx, domain, limit, offset) | |
| 157 | 157 | } | |
| 158 | 158 | ||
| 159 | + | func (r *LazyHistoryRepository) GetRecentWindow(ctx context.Context, before, after time.Time) ([]*entity.HistoryEntry, error) { | |
| 160 | + | if err := r.init(ctx); err != nil { | |
| 161 | + | return nil, err | |
| 162 | + | } | |
| 163 | + | return r.repo.GetRecentWindow(ctx, before, after) | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func (r *LazyHistoryRepository) GetRecentWindowByDomain(ctx context.Context, domain string, before, after time.Time) ([]*entity.HistoryEntry, error) { | |
| 167 | + | if err := r.init(ctx); err != nil { | |
| 168 | + | return nil, err | |
| 169 | + | } | |
| 170 | + | return r.repo.GetRecentWindowByDomain(ctx, domain, before, after) | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func (r *LazyHistoryRepository) HasEntriesBefore(ctx context.Context, before time.Time) (bool, error) { | |
| 174 | + | if err := r.init(ctx); err != nil { | |
| 175 | + | return false, err | |
| 176 | + | } | |
| 177 | + | return r.repo.HasEntriesBefore(ctx, before) | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func (r *LazyHistoryRepository) HasEntriesByDomainBefore(ctx context.Context, domain string, before time.Time) (bool, error) { | |
| 181 | + | if err := r.init(ctx); err != nil { | |
| 182 | + | return false, err | |
| 183 | + | } | |
| 184 | + | return r.repo.HasEntriesByDomainBefore(ctx, domain, before) | |
| 185 | + | } | |
| 186 | + | ||
| 159 | 187 | func (r *LazyHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | |
| 160 | 188 | if err := r.init(ctx); err != nil { | |
| 161 | 189 | return nil, err | |
M internal/infrastructure/persistence/sqlite/queries/history.sql +21 -0
| @@ -7,6 +7,27 @@ WHERE domain = @domain | |||
| 7 | 7 | ORDER BY last_visited DESC | |
| 8 | 8 | LIMIT @limit OFFSET @offset; | |
| 9 | 9 | ||
| 10 | + | -- name: GetAllRecentHistoryByDomain :many | |
| 11 | + | SELECT * FROM history | |
| 12 | + | WHERE domain = @domain | |
| 13 | + | ORDER BY last_visited DESC; | |
| 14 | + | ||
| 15 | + | -- name: GetRecentHistoryWindow :many | |
| 16 | + | SELECT * FROM history | |
| 17 | + | WHERE last_visited < @before AND last_visited >= @after | |
| 18 | + | ORDER BY last_visited DESC; | |
| 19 | + | ||
| 20 | + | -- name: GetRecentHistoryWindowByDomain :many | |
| 21 | + | SELECT * FROM history | |
| 22 | + | WHERE domain = @domain AND last_visited < @before AND last_visited >= @after | |
| 23 | + | ORDER BY last_visited DESC; | |
| 24 | + | ||
| 25 | + | -- name: HasHistoryBefore :one | |
| 26 | + | SELECT EXISTS(SELECT 1 FROM history WHERE last_visited < @before LIMIT 1); | |
| 27 | + | ||
| 28 | + | -- name: HasHistoryByDomainBefore :one | |
| 29 | + | SELECT EXISTS(SELECT 1 FROM history WHERE domain = @domain AND last_visited < @before LIMIT 1); | |
| 30 | + | ||
| 10 | 31 | -- name: GetHistoryByURL :one | |
| 11 | 32 | SELECT * FROM history WHERE url = ? LIMIT 1; | |
| 12 | 33 | ||
M internal/infrastructure/persistence/sqlite/sqlc/history.sql.go +152 -0
| @@ -144,6 +144,44 @@ func (q *Queries) GetAllRecentHistory(ctx context.Context) ([]History, error) { | |||
| 144 | 144 | return items, nil | |
| 145 | 145 | } | |
| 146 | 146 | ||
| 147 | + | const GetAllRecentHistoryByDomain = `-- name: GetAllRecentHistoryByDomain :many | |
| 148 | + | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | |
| 149 | + | WHERE domain = ?1 | |
| 150 | + | ORDER BY last_visited DESC | |
| 151 | + | ` | |
| 152 | + | ||
| 153 | + | func (q *Queries) GetAllRecentHistoryByDomain(ctx context.Context, domain sql.NullString) ([]History, error) { | |
| 154 | + | rows, err := q.db.QueryContext(ctx, GetAllRecentHistoryByDomain, domain) | |
| 155 | + | if err != nil { | |
| 156 | + | return nil, err | |
| 157 | + | } | |
| 158 | + | defer rows.Close() | |
| 159 | + | items := []History{} | |
| 160 | + | for rows.Next() { | |
| 161 | + | var i History | |
| 162 | + | if err := rows.Scan( | |
| 163 | + | &i.ID, | |
| 164 | + | &i.Url, | |
| 165 | + | &i.Title, | |
| 166 | + | &i.FaviconUrl, | |
| 167 | + | &i.VisitCount, | |
| 168 | + | &i.LastVisited, | |
| 169 | + | &i.CreatedAt, | |
| 170 | + | &i.Domain, | |
| 171 | + | ); err != nil { | |
| 172 | + | return nil, err | |
| 173 | + | } | |
| 174 | + | items = append(items, i) | |
| 175 | + | } | |
| 176 | + | if err := rows.Close(); err != nil { | |
| 177 | + | return nil, err | |
| 178 | + | } | |
| 179 | + | if err := rows.Err(); err != nil { | |
| 180 | + | return nil, err | |
| 181 | + | } | |
| 182 | + | return items, nil | |
| 183 | + | } | |
| 184 | + | ||
| 147 | 185 | const GetDailyVisitCount = `-- name: GetDailyVisitCount :many | |
| 148 | 186 | SELECT | |
| 149 | 187 | date(last_visited) as day, | |
| @@ -472,6 +510,120 @@ func (q *Queries) GetRecentHistorySince(ctx context.Context, datetime interface{ | |||
| 472 | 510 | return items, nil | |
| 473 | 511 | } | |
| 474 | 512 | ||
| 513 | + | const GetRecentHistoryWindow = `-- name: GetRecentHistoryWindow :many | |
| 514 | + | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | |
| 515 | + | WHERE last_visited < ?1 AND last_visited >= ?2 | |
| 516 | + | ORDER BY last_visited DESC | |
| 517 | + | ` | |
| 518 | + | ||
| 519 | + | type GetRecentHistoryWindowParams struct { | |
| 520 | + | Before sql.NullTime `json:"before"` | |
| 521 | + | After sql.NullTime `json:"after"` | |
| 522 | + | } | |
| 523 | + | ||
| 524 | + | func (q *Queries) GetRecentHistoryWindow(ctx context.Context, arg GetRecentHistoryWindowParams) ([]History, error) { | |
| 525 | + | rows, err := q.db.QueryContext(ctx, GetRecentHistoryWindow, arg.Before, arg.After) | |
| 526 | + | if err != nil { | |
| 527 | + | return nil, err | |
| 528 | + | } | |
| 529 | + | defer rows.Close() | |
| 530 | + | items := []History{} | |
| 531 | + | for rows.Next() { | |
| 532 | + | var i History | |
| 533 | + | if err := rows.Scan( | |
| 534 | + | &i.ID, | |
| 535 | + | &i.Url, | |
| 536 | + | &i.Title, | |
| 537 | + | &i.FaviconUrl, | |
| 538 | + | &i.VisitCount, | |
| 539 | + | &i.LastVisited, | |
| 540 | + | &i.CreatedAt, | |
| 541 | + | &i.Domain, | |
| 542 | + | ); err != nil { | |
| 543 | + | return nil, err | |
| 544 | + | } | |
| 545 | + | items = append(items, i) | |
| 546 | + | } | |
| 547 | + | if err := rows.Close(); err != nil { | |
| 548 | + | return nil, err | |
| 549 | + | } | |
| 550 | + | if err := rows.Err(); err != nil { | |
| 551 | + | return nil, err | |
| 552 | + | } | |
| 553 | + | return items, nil | |
| 554 | + | } | |
| 555 | + | ||
| 556 | + | const GetRecentHistoryWindowByDomain = `-- name: GetRecentHistoryWindowByDomain :many | |
| 557 | + | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | |
| 558 | + | WHERE domain = ?1 AND last_visited < ?2 AND last_visited >= ?3 | |
| 559 | + | ORDER BY last_visited DESC | |
| 560 | + | ` | |
| 561 | + | ||
| 562 | + | type GetRecentHistoryWindowByDomainParams struct { | |
| 563 | + | Domain sql.NullString `json:"domain"` | |
| 564 | + | Before sql.NullTime `json:"before"` | |
| 565 | + | After sql.NullTime `json:"after"` | |
| 566 | + | } | |
| 567 | + | ||
| 568 | + | func (q *Queries) GetRecentHistoryWindowByDomain(ctx context.Context, arg GetRecentHistoryWindowByDomainParams) ([]History, error) { | |
| 569 | + | rows, err := q.db.QueryContext(ctx, GetRecentHistoryWindowByDomain, arg.Domain, arg.Before, arg.After) | |
| 570 | + | if err != nil { | |
| 571 | + | return nil, err | |
| 572 | + | } | |
| 573 | + | defer rows.Close() | |
| 574 | + | items := []History{} | |
| 575 | + | for rows.Next() { | |
| 576 | + | var i History | |
| 577 | + | if err := rows.Scan( | |
| 578 | + | &i.ID, | |
| 579 | + | &i.Url, | |
| 580 | + | &i.Title, | |
| 581 | + | &i.FaviconUrl, | |
| 582 | + | &i.VisitCount, | |
| 583 | + | &i.LastVisited, | |
| 584 | + | &i.CreatedAt, | |
| 585 | + | &i.Domain, | |
| 586 | + | ); err != nil { | |
| 587 | + | return nil, err | |
| 588 | + | } | |
| 589 | + | items = append(items, i) | |
| 590 | + | } | |
| 591 | + | if err := rows.Close(); err != nil { | |
| 592 | + | return nil, err | |
| 593 | + | } | |
| 594 | + | if err := rows.Err(); err != nil { | |
| 595 | + | return nil, err | |
| 596 | + | } | |
| 597 | + | return items, nil | |
| 598 | + | } | |
| 599 | + | ||
| 600 | + | const HasHistoryBefore = `-- name: HasHistoryBefore :one | |
| 601 | + | SELECT EXISTS(SELECT 1 FROM history WHERE last_visited < ?1 LIMIT 1) | |
| 602 | + | ` | |
| 603 | + | ||
| 604 | + | func (q *Queries) HasHistoryBefore(ctx context.Context, before sql.NullTime) (int64, error) { | |
| 605 | + | row := q.db.QueryRowContext(ctx, HasHistoryBefore, before) | |
| 606 | + | var column_1 int64 | |
| 607 | + | err := row.Scan(&column_1) | |
| 608 | + | return column_1, err | |
| 609 | + | } | |
| 610 | + | ||
| 611 | + | const HasHistoryByDomainBefore = `-- name: HasHistoryByDomainBefore :one | |
| 612 | + | SELECT EXISTS(SELECT 1 FROM history WHERE domain = ?1 AND last_visited < ?2 LIMIT 1) | |
| 613 | + | ` | |
| 614 | + | ||
| 615 | + | type HasHistoryByDomainBeforeParams struct { | |
| 616 | + | Domain sql.NullString `json:"domain"` | |
| 617 | + | Before sql.NullTime `json:"before"` | |
| 618 | + | } | |
| 619 | + | ||
| 620 | + | func (q *Queries) HasHistoryByDomainBefore(ctx context.Context, arg HasHistoryByDomainBeforeParams) (int64, error) { | |
| 621 | + | row := q.db.QueryRowContext(ctx, HasHistoryByDomainBefore, arg.Domain, arg.Before) | |
| 622 | + | var column_1 int64 | |
| 623 | + | err := row.Scan(&column_1) | |
| 624 | + | return column_1, err | |
| 625 | + | } | |
| 626 | + | ||
| 475 | 627 | const IncrementVisitCount = `-- name: IncrementVisitCount :exec | |
| 476 | 628 | UPDATE history | |
| 477 | 629 | SET visit_count = visit_count + 1, last_visited = CURRENT_TIMESTAMP | |
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +5 -0
| @@ -37,6 +37,7 @@ type Querier interface { | |||
| 37 | 37 | GetAllFolders(ctx context.Context) ([]FavoriteFolder, error) | |
| 38 | 38 | GetAllMostVisited(ctx context.Context) ([]History, error) | |
| 39 | 39 | GetAllRecentHistory(ctx context.Context) ([]History, error) | |
| 40 | + | GetAllRecentHistoryByDomain(ctx context.Context, domain sql.NullString) ([]History, error) | |
| 40 | 41 | GetAllSessionStates(ctx context.Context) ([]SessionState, error) | |
| 41 | 42 | GetAllTags(ctx context.Context) ([]FavoriteTag, error) | |
| 42 | 43 | GetAllWhitelistedDomains(ctx context.Context) ([]string, error) | |
| @@ -57,6 +58,8 @@ type Querier interface { | |||
| 57 | 58 | GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error) | |
| 58 | 59 | GetRecentHistoryByDomain(ctx context.Context, arg GetRecentHistoryByDomainParams) ([]History, error) | |
| 59 | 60 | GetRecentHistorySince(ctx context.Context, datetime interface{}) ([]History, error) | |
| 61 | + | GetRecentHistoryWindow(ctx context.Context, arg GetRecentHistoryWindowParams) ([]History, error) | |
| 62 | + | GetRecentHistoryWindowByDomain(ctx context.Context, arg GetRecentHistoryWindowByDomainParams) ([]History, error) | |
| 60 | 63 | GetRecentSessions(ctx context.Context, limit int64) ([]Session, error) | |
| 61 | 64 | GetRootFolders(ctx context.Context) ([]FavoriteFolder, error) | |
| 62 | 65 | GetSessionByID(ctx context.Context, id string) (Session, error) | |
| @@ -67,6 +70,8 @@ type Querier interface { | |||
| 67 | 70 | GetTagsForFavorite(ctx context.Context, favoriteID int64) ([]FavoriteTag, error) | |
| 68 | 71 | GetTotalSessionStatesSize(ctx context.Context) (interface{}, error) | |
| 69 | 72 | GetZoomLevel(ctx context.Context, domain string) (ZoomLevel, error) | |
| 73 | + | HasHistoryBefore(ctx context.Context, before sql.NullTime) (int64, error) | |
| 74 | + | HasHistoryByDomainBefore(ctx context.Context, arg HasHistoryByDomainBeforeParams) (int64, error) | |
| 70 | 75 | IncrementVisitCount(ctx context.Context, url string) error | |
| 71 | 76 | IncrementVisitCountByDelta(ctx context.Context, arg IncrementVisitCountByDeltaParams) error | |
| 72 | 77 | InsertSession(ctx context.Context, arg InsertSessionParams) error | |
M internal/infrastructure/systemviewsbridge/browser_transport_js.go +1 -1
| @@ -24,7 +24,7 @@ func NewBrowserClient() *Client { | |||
| 24 | 24 | ||
| 25 | 25 | func callbackPlanForMessage(msgType string) (callbackPlan, bool) { | |
| 26 | 26 | switch msgType { | |
| 27 | - | case "history_timeline", "history_timeline_by_domain", "history_search_fts", "history_delete_entry", "history_delete_range", "history_analytics", | |
| 27 | + | case "history_timeline", "history_timeline_by_domain", "history_timeline_window", "history_search_fts", "history_delete_entry", "history_delete_range", "history_stats", "history_analytics", | |
| 28 | 28 | "history_domain_stats", "history_delete_domain", "favorite_list", "favorite_create", "favorite_update", "favorite_delete", "folder_list", "tag_list", | |
| 29 | 29 | "favorite_set_shortcut", "favorite_set_folder", "folder_create", "folder_update", "folder_delete", | |
| 30 | 30 | "tag_create", "tag_update", "tag_delete", "tag_assign", "tag_remove": | |
M internal/infrastructure/systemviewsbridge/client.go +18 -0
| @@ -108,6 +108,18 @@ func (c *Client) TimelineByDomain(ctx context.Context, domain string, limit, off | |||
| 108 | 108 | }{RequestID: nextRequestID(), Domain: domain, Limit: limit, Offset: offset}) | |
| 109 | 109 | } | |
| 110 | 110 | ||
| 111 | + | func (c *Client) TimelineWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) { | |
| 112 | + | beforeCursor := "" | |
| 113 | + | if !before.IsZero() { | |
| 114 | + | beforeCursor = before.Format(time.RFC3339Nano) | |
| 115 | + | } | |
| 116 | + | return request[*entity.HistoryWindow](c, ctx, "history_timeline_window", struct { | |
| 117 | + | RequestID string `json:"requestId"` | |
| 118 | + | Before string `json:"before,omitempty"` | |
| 119 | + | Domain string `json:"domain,omitempty"` | |
| 120 | + | }{RequestID: nextRequestID(), Before: beforeCursor, Domain: strings.TrimSpace(domain)}) | |
| 121 | + | } | |
| 122 | + | ||
| 111 | 123 | func (c *Client) Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error) { | |
| 112 | 124 | if limit < 0 { | |
| 113 | 125 | return nil, fmt.Errorf("search limit must be non-negative, got %d", limit) | |
| @@ -185,6 +197,12 @@ func (c *Client) ResetAllKeybindings(ctx context.Context) error { | |||
| 185 | 197 | return err | |
| 186 | 198 | } | |
| 187 | 199 | ||
| 200 | + | func (c *Client) Stats(ctx context.Context) (*entity.HistoryStats, error) { | |
| 201 | + | return request[*entity.HistoryStats](c, ctx, "history_stats", struct { | |
| 202 | + | RequestID string `json:"requestId"` | |
| 203 | + | }{RequestID: nextRequestID()}) | |
| 204 | + | } | |
| 205 | + | ||
| 188 | 206 | func (c *Client) Analytics(ctx context.Context) (*entity.HistoryAnalytics, error) { | |
| 189 | 207 | return request[*entity.HistoryAnalytics](c, ctx, "history_analytics", struct { | |
| 190 | 208 | RequestID string `json:"requestId"` | |
M internal/infrastructure/systemviewsbridge/client_test.go +27 -0
| @@ -127,6 +127,33 @@ func TestClientTimelineDecodesEntries(t *testing.T) { | |||
| 127 | 127 | } | |
| 128 | 128 | } | |
| 129 | 129 | ||
| 130 | + | func TestClientTimelineWindowOmitsZeroCursor(t *testing.T) { | |
| 131 | + | t.Parallel() | |
| 132 | + | ||
| 133 | + | native := &fakeTransport{available: true, response: []byte(`{"requestId":"req-window","success":true}`)} | |
| 134 | + | client := NewClient(native, nil) | |
| 135 | + | ||
| 136 | + | _, err := client.TimelineWindow(context.Background(), time.Time{}, "") | |
| 137 | + | if err != nil { | |
| 138 | + | t.Fatalf("TimelineWindow() error = %v", err) | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | var msg port.WebUIMessage | |
| 142 | + | if err := json.Unmarshal(native.last, &msg); err != nil { | |
| 143 | + | t.Fatalf("unmarshal sent envelope: %v", err) | |
| 144 | + | } | |
| 145 | + | if msg.Type != "history_timeline_window" { | |
| 146 | + | t.Fatalf("sent type = %q, want %q", msg.Type, "history_timeline_window") | |
| 147 | + | } | |
| 148 | + | var payload map[string]any | |
| 149 | + | if err := json.Unmarshal(msg.Payload, &payload); err != nil { | |
| 150 | + | t.Fatalf("unmarshal payload: %v", err) | |
| 151 | + | } | |
| 152 | + | if _, ok := payload["before"]; ok { | |
| 153 | + | t.Fatalf("zero TimelineWindow cursor should be omitted, payload = %s", msg.Payload) | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | ||
| 130 | 157 | func TestClientDeleteRangeRejectsEmptyRange(t *testing.T) { | |
| 131 | 158 | t.Parallel() | |
| 132 | 159 | ||
A internal/infrastructure/webkit/favicon_systemviews.go +14 -0
| @@ -0,0 +1,14 @@ | |||
| 1 | + | package webkit | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/dumber/internal/application/port" | |
| 4 | + | ||
| 5 | + | var _ port.SystemviewFaviconServiceSetter = (*Engine)(nil) | |
| 6 | + | ||
| 7 | + | // SetSystemviewFaviconService exposes cached favicons to systemview pages via | |
| 8 | + | // dumb://api/favicon without triggering remote favicon fetches during History rendering. | |
| 9 | + | func (e *Engine) SetSystemviewFaviconService(service port.FaviconService) { | |
| 10 | + | if e == nil || e.schemeHandler == nil { | |
| 11 | + | return | |
| 12 | + | } | |
| 13 | + | e.schemeHandler.SetFaviconService(service) | |
| 14 | + | } | |
M internal/infrastructure/webkit/scheme_handler.go +162 -26
| @@ -9,11 +9,15 @@ import ( | |||
| 9 | 9 | "io/fs" | |
| 10 | 10 | "net/http" | |
| 11 | 11 | "net/url" | |
| 12 | + | "os" | |
| 12 | 13 | "path" | |
| 14 | + | "strconv" | |
| 13 | 15 | "strings" | |
| 14 | 16 | "sync" | |
| 15 | 17 | ||
| 16 | 18 | "github.com/andybalholm/brotli" | |
| 19 | + | "github.com/bnema/dumber/internal/application/port" | |
| 20 | + | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| 17 | 21 | "github.com/bnema/dumber/internal/infrastructure/webutil" | |
| 18 | 22 | "github.com/bnema/dumber/internal/logging" | |
| 19 | 23 | "github.com/bnema/puregotk/v4/gio" | |
| @@ -37,19 +41,22 @@ const ( | |||
| 37 | 41 | ||
| 38 | 42 | // SchemeRequest represents a request to a custom URI scheme. | |
| 39 | 43 | type SchemeRequest struct { | |
| 40 | - | inner *webkit.URISchemeRequest | |
| 41 | - | URI string | |
| 42 | - | Path string | |
| 43 | - | Method string | |
| 44 | - | Scheme string | |
| 44 | + | inner *webkit.URISchemeRequest | |
| 45 | + | URI string | |
| 46 | + | Path string | |
| 47 | + | Method string | |
| 48 | + | Scheme string | |
| 49 | + | Origin string | |
| 50 | + | Referer string | |
| 45 | 51 | } | |
| 46 | 52 | ||
| 47 | 53 | // SchemeResponse represents a response to a scheme request. | |
| 48 | 54 | type SchemeResponse struct { | |
| 49 | - | Data []byte | |
| 50 | - | ContentType string | |
| 51 | - | StatusCode int | |
| 52 | - | Headers map[string]string | |
| 55 | + | Data []byte | |
| 56 | + | ContentType string | |
| 57 | + | StatusCode int | |
| 58 | + | Headers map[string]string | |
| 59 | + | SuppressDefaultHeaders bool | |
| 53 | 60 | } | |
| 54 | 61 | ||
| 55 | 62 | // PageHandler generates content for a specific page path. | |
| @@ -68,6 +75,7 @@ func (f PageHandlerFunc) Handle(req *SchemeRequest) *SchemeResponse { | |||
| 68 | 75 | type DumbSchemeHandler struct { | |
| 69 | 76 | handlers map[string]PageHandler | |
| 70 | 77 | assets embed.FS | |
| 78 | + | faviconService port.FaviconService | |
| 71 | 79 | assetDir string // default subdirectory within embed.FS (e.g., "systemviews") | |
| 72 | 80 | logger zerolog.Logger | |
| 73 | 81 | mu sync.RWMutex | |
| @@ -108,6 +116,12 @@ func (h *DumbSchemeHandler) SetConfigPayloadBuilders(current, defaultPayload fun | |||
| 108 | 116 | h.logger.Debug().Msg("config payload builders configured") | |
| 109 | 117 | } | |
| 110 | 118 | ||
| 119 | + | func (h *DumbSchemeHandler) SetFaviconService(service port.FaviconService) { | |
| 120 | + | h.mu.Lock() | |
| 121 | + | defer h.mu.Unlock() | |
| 122 | + | h.faviconService = service | |
| 123 | + | } | |
| 124 | + | ||
| 111 | 125 | // registerDefaults sets up default page handlers. | |
| 112 | 126 | func (h *DumbSchemeHandler) registerDefaults() { | |
| 113 | 127 | // Error page (static fallback) | |
| @@ -137,6 +151,9 @@ func (h *DumbSchemeHandler) registerDefaults() { | |||
| 137 | 151 | if req.Method != "" && req.Method != httpGET { | |
| 138 | 152 | return nil | |
| 139 | 153 | } | |
| 154 | + | if !isTrustedSystemviewAPIRequest(req) { | |
| 155 | + | return privateJSONErrorResponse(http.StatusForbidden, "forbidden") | |
| 156 | + | } | |
| 140 | 157 | ||
| 141 | 158 | h.mu.RLock() | |
| 142 | 159 | build := h.currentConfigPayload | |
| @@ -149,12 +166,22 @@ func (h *DumbSchemeHandler) registerDefaults() { | |||
| 149 | 166 | if req.Method != "" && req.Method != httpGET { | |
| 150 | 167 | return nil | |
| 151 | 168 | } | |
| 169 | + | if !isTrustedSystemviewAPIRequest(req) { | |
| 170 | + | return privateJSONErrorResponse(http.StatusForbidden, "forbidden") | |
| 171 | + | } | |
| 152 | 172 | ||
| 153 | 173 | h.mu.RLock() | |
| 154 | 174 | build := h.defaultConfigPayload | |
| 155 | 175 | h.mu.RUnlock() | |
| 156 | 176 | return buildConfigResponse(build) | |
| 157 | 177 | })) | |
| 178 | + | ||
| 179 | + | h.RegisterPage("/api/favicon", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse { | |
| 180 | + | if req.Method != "" && req.Method != httpGET { | |
| 181 | + | return nil | |
| 182 | + | } | |
| 183 | + | return h.handleFaviconAPI(req) | |
| 184 | + | })) | |
| 158 | 185 | } | |
| 159 | 186 | ||
| 160 | 187 | func crashOriginalURI(requestURI string) string { | |
| @@ -176,28 +203,129 @@ func buildCrashPageHTML(originalURI string) string { | |||
| 176 | 203 | return webutil.BuildCrashPageHTML(originalURI) | |
| 177 | 204 | } | |
| 178 | 205 | ||
| 206 | + | const systemviewFaviconSize = 32 | |
| 207 | + | ||
| 208 | + | func (h *DumbSchemeHandler) handleFaviconAPI(req *SchemeRequest) *SchemeResponse { | |
| 209 | + | if !isTrustedSystemviewFaviconRequest(req) { | |
| 210 | + | return privateJSONErrorResponse(http.StatusForbidden, "forbidden") | |
| 211 | + | } | |
| 212 | + | h.mu.RLock() | |
| 213 | + | service := h.faviconService | |
| 214 | + | h.mu.RUnlock() | |
| 215 | + | if service == nil { | |
| 216 | + | return privateJSONErrorResponse(http.StatusNotFound, "favicon unavailable") | |
| 217 | + | } | |
| 218 | + | if req == nil { | |
| 219 | + | return privateJSONErrorResponse(http.StatusBadRequest, "invalid request") | |
| 220 | + | } | |
| 221 | + | parsed, err := url.Parse(req.URI) | |
| 222 | + | if err != nil { | |
| 223 | + | return privateJSONErrorResponse(http.StatusBadRequest, "invalid request URL") | |
| 224 | + | } | |
| 225 | + | domain := domainurl.CanonicalDomain(parsed.Query().Get("domain")) | |
| 226 | + | if domain == "" { | |
| 227 | + | return privateJSONErrorResponse(http.StatusBadRequest, "missing domain") | |
| 228 | + | } | |
| 229 | + | size := systemviewFaviconSize | |
| 230 | + | if rawSize := strings.TrimSpace(parsed.Query().Get("size")); rawSize != "" { | |
| 231 | + | parsedSize, parseErr := strconv.Atoi(rawSize) | |
| 232 | + | if parseErr != nil || parsedSize != systemviewFaviconSize { | |
| 233 | + | return privateJSONErrorResponse(http.StatusBadRequest, "unsupported favicon size") | |
| 234 | + | } | |
| 235 | + | size = parsedSize | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | if !service.HasPNGSizedOnDisk(domain, size) { | |
| 239 | + | return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached") | |
| 240 | + | } | |
| 241 | + | path := service.DiskPathPNGSized(domain, size) | |
| 242 | + | if path == "" { | |
| 243 | + | return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached") | |
| 244 | + | } | |
| 245 | + | data, err := os.ReadFile(path) | |
| 246 | + | if err != nil || len(data) == 0 { | |
| 247 | + | return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached") | |
| 248 | + | } | |
| 249 | + | return &SchemeResponse{ | |
| 250 | + | Data: data, | |
| 251 | + | ContentType: "image/png", | |
| 252 | + | StatusCode: http.StatusOK, | |
| 253 | + | Headers: map[string]string{"Cache-Control": "no-store"}, | |
| 254 | + | SuppressDefaultHeaders: true, | |
| 255 | + | } | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | func isTrustedSystemviewFaviconRequest(req *SchemeRequest) bool { | |
| 259 | + | return isTrustedSystemviewAPIRequest(req) | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | func isTrustedSystemviewAPIRequest(req *SchemeRequest) bool { | |
| 263 | + | if req == nil { | |
| 264 | + | return false | |
| 265 | + | } | |
| 266 | + | origin := strings.TrimSpace(req.Origin) | |
| 267 | + | if origin != "" { | |
| 268 | + | return isTrustedSystemviewURL(origin) | |
| 269 | + | } | |
| 270 | + | return isTrustedSystemviewURL(req.Referer) | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | func isTrustedSystemviewURL(raw string) bool { | |
| 274 | + | parsed, err := url.Parse(strings.TrimSpace(raw)) | |
| 275 | + | if err != nil || parsed.Scheme == "" { | |
| 276 | + | return false | |
| 277 | + | } | |
| 278 | + | if !strings.EqualFold(parsed.Scheme, "dumb") { | |
| 279 | + | return false | |
| 280 | + | } | |
| 281 | + | host := parsed.Host | |
| 282 | + | if host == "" { | |
| 283 | + | host = parsed.Opaque | |
| 284 | + | } | |
| 285 | + | if idx := strings.IndexAny(host, "/?#"); idx >= 0 { | |
| 286 | + | host = host[:idx] | |
| 287 | + | } | |
| 288 | + | switch host { | |
| 289 | + | case HistoryPath, FavoritesPath, ConfigPath, ErrorPath, CrashPath: | |
| 290 | + | return true | |
| 291 | + | default: | |
| 292 | + | return false | |
| 293 | + | } | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | func jsonErrorResponse(status int, message string) *SchemeResponse { | |
| 297 | + | return &SchemeResponse{ | |
| 298 | + | Data: []byte(fmt.Sprintf(`{"error":%q}`, message)), | |
| 299 | + | ContentType: "application/json", | |
| 300 | + | StatusCode: status, | |
| 301 | + | } | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | func privateJSONErrorResponse(status int, message string) *SchemeResponse { | |
| 305 | + | resp := jsonErrorResponse(status, message) | |
| 306 | + | resp.Headers = map[string]string{"Cache-Control": "no-store"} | |
| 307 | + | resp.SuppressDefaultHeaders = true | |
| 308 | + | return resp | |
| 309 | + | } | |
| 310 | + | ||
| 179 | 311 | func buildConfigResponse(build func() ([]byte, error)) *SchemeResponse { | |
| 180 | 312 | if build == nil { | |
| 181 | - | return &SchemeResponse{ | |
| 182 | - | Data: []byte(`{"error": "config payload builder not configured"}`), | |
| 183 | - | ContentType: "application/json", | |
| 184 | - | StatusCode: http.StatusInternalServerError, | |
| 185 | - | } | |
| 313 | + | resp := privateJSONErrorResponse(http.StatusInternalServerError, "config payload builder not configured") | |
| 314 | + | return resp | |
| 186 | 315 | } | |
| 187 | 316 | ||
| 188 | 317 | data, err := build() | |
| 189 | 318 | if err != nil { | |
| 190 | - | return &SchemeResponse{ | |
| 191 | - | Data: []byte(fmt.Sprintf(`{"error": %q}`, err)), | |
| 192 | - | ContentType: "application/json", | |
| 193 | - | StatusCode: http.StatusInternalServerError, | |
| 194 | - | } | |
| 319 | + | resp := privateJSONErrorResponse(http.StatusInternalServerError, err.Error()) | |
| 320 | + | return resp | |
| 195 | 321 | } | |
| 196 | 322 | ||
| 197 | 323 | return &SchemeResponse{ | |
| 198 | - | Data: data, | |
| 199 | - | ContentType: "application/json", | |
| 200 | - | StatusCode: http.StatusOK, | |
| 324 | + | Data: data, | |
| 325 | + | ContentType: "application/json", | |
| 326 | + | StatusCode: http.StatusOK, | |
| 327 | + | Headers: map[string]string{"Cache-Control": "no-store"}, | |
| 328 | + | SuppressDefaultHeaders: true, | |
| 201 | 329 | } | |
| 202 | 330 | } | |
| 203 | 331 | ||
| @@ -217,6 +345,7 @@ func (h *DumbSchemeHandler) HandleRequest(reqPtr uintptr) { | |||
| 217 | 345 | } | |
| 218 | 346 | ||
| 219 | 347 | uri := req.GetUri() | |
| 348 | + | requestHeaders := req.GetHttpHeaders() | |
| 220 | 349 | schemeReq := &SchemeRequest{ | |
| 221 | 350 | inner: req, | |
| 222 | 351 | URI: uri, | |
| @@ -224,6 +353,10 @@ func (h *DumbSchemeHandler) HandleRequest(reqPtr uintptr) { | |||
| 224 | 353 | Method: req.GetHttpMethod(), | |
| 225 | 354 | Scheme: req.GetScheme(), | |
| 226 | 355 | } | |
| 356 | + | if requestHeaders != nil { | |
| 357 | + | schemeReq.Origin = strings.TrimSpace(requestHeaders.GetOne("Origin")) | |
| 358 | + | schemeReq.Referer = strings.TrimSpace(requestHeaders.GetOne("Referer")) | |
| 359 | + | } | |
| 227 | 360 | ||
| 228 | 361 | h.logger.Debug(). | |
| 229 | 362 | Str("uri", schemeReq.URI). | |
| @@ -412,7 +545,7 @@ func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { | |||
| 412 | 545 | ||
| 413 | 546 | func shouldAddCORSHeaders(path string) bool { | |
| 414 | 547 | path = strings.TrimSpace(path) | |
| 415 | - | return strings.HasPrefix(path, "/api/") || strings.HasSuffix(path, ".wasm") | |
| 548 | + | return strings.HasSuffix(path, ".wasm") | |
| 416 | 549 | } | |
| 417 | 550 | ||
| 418 | 551 | func responseHeadersForPath(path, contentType string) map[string]string { | |
| @@ -463,9 +596,12 @@ func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response | |||
| 463 | 596 | schemeResp.SetContentType(contentType) | |
| 464 | 597 | schemeResp.SetStatus(uint(response.StatusCode), nil) | |
| 465 | 598 | ||
| 466 | - | // WebKit can treat custom schemes as CORS-relevant even for same-origin fetch(). | |
| 467 | - | // Add CORS headers to fetch-backed endpoints, including the wasm runtime asset. | |
| 468 | - | headers := responseHeadersForPath(req.GetPath(), contentType) | |
| 599 | + | // WebKit can treat custom schemes as CORS-relevant for the wasm runtime asset. | |
| 600 | + | // Private systemview APIs opt out with SuppressDefaultHeaders and validate callers. | |
| 601 | + | var headers map[string]string | |
| 602 | + | if !response.SuppressDefaultHeaders { | |
| 603 | + | headers = responseHeadersForPath(req.GetPath(), contentType) | |
| 604 | + | } | |
| 469 | 605 | for name, value := range response.Headers { | |
| 470 | 606 | if headers == nil { | |
| 471 | 607 | headers = map[string]string{} | |
M internal/infrastructure/webkit/scheme_handler_test.go +67 -9
| @@ -50,6 +50,41 @@ func TestBuildCrashPageHTMLEscapesScriptBreakoutPayload(t *testing.T) { | |||
| 50 | 50 | assert.Contains(t, body, "</script><script>alert(1)</script>") | |
| 51 | 51 | } | |
| 52 | 52 | ||
| 53 | + | func TestIsTrustedSystemviewURL(t *testing.T) { | |
| 54 | + | t.Parallel() | |
| 55 | + | ||
| 56 | + | for _, raw := range []string{ | |
| 57 | + | "dumb://history", | |
| 58 | + | "dumb://favorites", | |
| 59 | + | "dumb:config", | |
| 60 | + | } { | |
| 61 | + | require.True(t, isTrustedSystemviewURL(raw), raw) | |
| 62 | + | } | |
| 63 | + | for _, raw := range []string{ | |
| 64 | + | "", | |
| 65 | + | "https://dumber.invalid/history", | |
| 66 | + | "dumb://api/favicon", | |
| 67 | + | "dumb://evil/history", | |
| 68 | + | } { | |
| 69 | + | require.False(t, isTrustedSystemviewURL(raw), raw) | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestIsTrustedSystemviewFaviconRequestRequiresTrustedRefererWhenOriginAbsent(t *testing.T) { | |
| 74 | + | t.Parallel() | |
| 75 | + | ||
| 76 | + | require.True(t, isTrustedSystemviewFaviconRequest(&SchemeRequest{Referer: "dumb://history"})) | |
| 77 | + | require.False(t, isTrustedSystemviewFaviconRequest(&SchemeRequest{Referer: ""})) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TestIsTrustedSystemviewFaviconRequestHonorsOriginBeforeReferer(t *testing.T) { | |
| 81 | + | t.Parallel() | |
| 82 | + | ||
| 83 | + | require.True(t, isTrustedSystemviewFaviconRequest(&SchemeRequest{Origin: "dumb://history"})) | |
| 84 | + | require.True(t, isTrustedSystemviewFaviconRequest(&SchemeRequest{Origin: "dumb://history", Referer: "https://evil.example"})) | |
| 85 | + | require.False(t, isTrustedSystemviewFaviconRequest(&SchemeRequest{Origin: "https://evil.example", Referer: "dumb://history"})) | |
| 86 | + | } | |
| 87 | + | ||
| 53 | 88 | func TestRegisterDefaultsIncludesCrashHandler(t *testing.T) { | |
| 54 | 89 | handler := NewDumbSchemeHandler(context.Background()) | |
| 55 | 90 | require.NotNil(t, handler) | |
| @@ -162,10 +197,11 @@ func TestConfigHandlersUseInjectedPayloadBuilders(t *testing.T) { | |||
| 162 | 197 | require.NotNil(t, defaultHandler) | |
| 163 | 198 | ||
| 164 | 199 | currentResp := currentHandler.Handle(&SchemeRequest{ | |
| 165 | - | URI: "dumb://config/api/config", | |
| 166 | - | Path: "/api/config", | |
| 167 | - | Method: http.MethodGet, | |
| 168 | - | Scheme: "dumb", | |
| 200 | + | URI: "dumb://config/api/config", | |
| 201 | + | Path: "/api/config", | |
| 202 | + | Method: http.MethodGet, | |
| 203 | + | Scheme: "dumb", | |
| 204 | + | Referer: "dumb://config", | |
| 169 | 205 | }) | |
| 170 | 206 | require.NotNil(t, currentResp) | |
| 171 | 207 | assert.Equal(t, http.StatusOK, currentResp.StatusCode) | |
| @@ -173,10 +209,11 @@ func TestConfigHandlersUseInjectedPayloadBuilders(t *testing.T) { | |||
| 173 | 209 | assert.Equal(t, []byte(`{"current":true}`), currentResp.Data) | |
| 174 | 210 | ||
| 175 | 211 | defaultResp := defaultHandler.Handle(&SchemeRequest{ | |
| 176 | - | URI: "dumb://config/api/config/default", | |
| 177 | - | Path: "/api/config/default", | |
| 178 | - | Method: http.MethodGet, | |
| 179 | - | Scheme: "dumb", | |
| 212 | + | URI: "dumb://config/api/config/default", | |
| 213 | + | Path: "/api/config/default", | |
| 214 | + | Method: http.MethodGet, | |
| 215 | + | Scheme: "dumb", | |
| 216 | + | Referer: "dumb://config", | |
| 180 | 217 | }) | |
| 181 | 218 | require.NotNil(t, defaultResp) | |
| 182 | 219 | assert.Equal(t, http.StatusOK, defaultResp.StatusCode) | |
| @@ -184,6 +221,27 @@ func TestConfigHandlersUseInjectedPayloadBuilders(t *testing.T) { | |||
| 184 | 221 | assert.Equal(t, []byte(`{"default":true}`), defaultResp.Data) | |
| 185 | 222 | } | |
| 186 | 223 | ||
| 224 | + | func TestConfigHandlersRejectUntrustedRequests(t *testing.T) { | |
| 225 | + | h := NewDumbSchemeHandler(context.Background()) | |
| 226 | + | h.SetConfigPayloadBuilders( | |
| 227 | + | func() ([]byte, error) { return []byte(`{"current":true}`), nil }, | |
| 228 | + | func() ([]byte, error) { return []byte(`{"default":true}`), nil }, | |
| 229 | + | ) | |
| 230 | + | ||
| 231 | + | h.mu.RLock() | |
| 232 | + | currentHandler := h.handlers["/api/config"] | |
| 233 | + | defaultHandler := h.handlers["/api/config/default"] | |
| 234 | + | h.mu.RUnlock() | |
| 235 | + | ||
| 236 | + | for _, handler := range []PageHandler{currentHandler, defaultHandler} { | |
| 237 | + | resp := handler.Handle(&SchemeRequest{Method: http.MethodGet, Origin: "https://evil.example"}) | |
| 238 | + | require.NotNil(t, resp) | |
| 239 | + | assert.Equal(t, http.StatusForbidden, resp.StatusCode) | |
| 240 | + | assert.True(t, resp.SuppressDefaultHeaders) | |
| 241 | + | assert.NotContains(t, resp.Headers, "Access-Control-Allow-Origin") | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | ||
| 187 | 245 | func TestShouldAddCORSHeaders(t *testing.T) { | |
| 188 | 246 | t.Parallel() | |
| 189 | 247 | ||
| @@ -192,7 +250,7 @@ func TestShouldAddCORSHeaders(t *testing.T) { | |||
| 192 | 250 | path string | |
| 193 | 251 | want bool | |
| 194 | 252 | }{ | |
| 195 | - | {name: "api route", path: "/api/config", want: true}, | |
| 253 | + | {name: "private api route", path: "/api/config", want: false}, | |
| 196 | 254 | {name: "wasm asset", path: "/systemviews.wasm", want: true}, | |
| 197 | 255 | {name: "html shell", path: "/", want: false}, | |
| 198 | 256 | {name: "js asset", path: "/wasm_exec.js", want: false}, | |
M internal/ui/app.go +4 -0
| @@ -204,6 +204,10 @@ func New(deps *Dependencies) (*App, error) { | |||
| 204 | 204 | glib.IdleAdd(&cb, 0) | |
| 205 | 205 | }) | |
| 206 | 206 | } | |
| 207 | + | if faviconSetter, ok := deps.Engine.(port.SystemviewFaviconServiceSetter); ok { | |
| 208 | + | faviconSetter.SetSystemviewFaviconService(deps.FaviconService) | |
| 209 | + | } | |
| 210 | + | ||
| 207 | 211 | // Register message handlers through the engine. | |
| 208 | 212 | if err := deps.Engine.RegisterHandlers(ctx, port.HandlerDependencies{ | |
| 209 | 213 | HistoryUC: deps.HistoryUC, | |
M internal/ui/systemviews/app.go +138 -22
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "fmt" | |
| 7 | 7 | "strings" | |
| 8 | 8 | "sync" | |
| 9 | + | "time" | |
| 9 | 10 | ||
| 10 | 11 | "github.com/bnema/dumber/internal/application/dto" | |
| 11 | 12 | "github.com/bnema/dumber/internal/application/port" | |
| @@ -29,11 +30,15 @@ type App struct { | |||
| 29 | 30 | currentRoute Route | |
| 30 | 31 | shellTheme shellTheme | |
| 31 | 32 | historyEntries []*entity.HistoryEntry | |
| 33 | + | historyStats *entity.HistoryStats | |
| 32 | 34 | historyAnalytics *entity.HistoryAnalytics | |
| 33 | 35 | historyDomainStats []*entity.DomainStat | |
| 34 | 36 | historyQuery string | |
| 35 | 37 | historyDomainFilter string | |
| 36 | 38 | historyOffset int | |
| 39 | + | historyWindowBefore time.Time | |
| 40 | + | historyWindowAfter time.Time | |
| 41 | + | historyHasMore bool | |
| 37 | 42 | historyNotice string | |
| 38 | 43 | historyError string | |
| 39 | 44 | favorites []*entity.Favorite | |
| @@ -56,7 +61,10 @@ type App struct { | |||
| 56 | 61 | actionWG sync.WaitGroup | |
| 57 | 62 | } | |
| 58 | 63 | ||
| 59 | - | const historyTimelineLimit = 25 | |
| 64 | + | const ( | |
| 65 | + | historyTimelineLimit = 0 | |
| 66 | + | historySearchLimit = 100 | |
| 67 | + | ) | |
| 60 | 68 | ||
| 61 | 69 | func (a *App) lockAction() { | |
| 62 | 70 | // Lock order: actionMu is never acquired while App.mu is held. | |
| @@ -96,7 +104,10 @@ func (a *App) RunWithContext(ctx context.Context) error { | |||
| 96 | 104 | if a.deps.DOM == nil { | |
| 97 | 105 | return errors.New("DOM not configured") | |
| 98 | 106 | } | |
| 99 | - | if err := a.LoadInitial(ctx); err != nil { | |
| 107 | + | asyncInitial := a.shouldLoadInitialRouteAsync() | |
| 108 | + | if asyncInitial { | |
| 109 | + | a.LoadInitialShell(ctx) | |
| 110 | + | } else if err := a.LoadInitial(ctx); err != nil { | |
| 100 | 111 | return err | |
| 101 | 112 | } | |
| 102 | 113 | ||
| @@ -104,11 +115,39 @@ func (a *App) RunWithContext(ctx context.Context) error { | |||
| 104 | 115 | return err | |
| 105 | 116 | } | |
| 106 | 117 | if binder, ok := a.deps.DOM.(DOMActionBinder); ok { | |
| 107 | - | return a.bindDOMActions(ctx, binder) | |
| 118 | + | if err := a.bindDOMActions(ctx, binder); err != nil { | |
| 119 | + | return err | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | if asyncInitial { | |
| 123 | + | a.refreshCurrentRouteAsync(ctx) | |
| 108 | 124 | } | |
| 109 | 125 | return nil | |
| 110 | 126 | } | |
| 111 | 127 | ||
| 128 | + | func (a *App) shouldLoadInitialRouteAsync() bool { | |
| 129 | + | return a != nil && a.currentRoute == RouteHistory && a.deps.History != nil | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func (a *App) refreshCurrentRouteAsync(ctx context.Context) { | |
| 133 | + | if a == nil { | |
| 134 | + | return | |
| 135 | + | } | |
| 136 | + | go func() { | |
| 137 | + | a.lockState() | |
| 138 | + | defer a.unlockState() | |
| 139 | + | if err := a.loadCurrentRoute(ctx); err != nil { | |
| 140 | + | a.renderRouteError(err) | |
| 141 | + | } | |
| 142 | + | html := a.renderedHTML | |
| 143 | + | go func() { | |
| 144 | + | if err := a.mountHTML(html); err != nil { | |
| 145 | + | logActionMountError(ctx, err, nil) | |
| 146 | + | } | |
| 147 | + | }() | |
| 148 | + | }() | |
| 149 | + | } | |
| 150 | + | ||
| 112 | 151 | func (a *App) bindDOMActions(ctx context.Context, binder DOMActionBinder) error { | |
| 113 | 152 | var ( | |
| 114 | 153 | created bool | |
| @@ -329,6 +368,28 @@ func (a *App) releaseDOMBindings() { | |||
| 329 | 368 | } | |
| 330 | 369 | } | |
| 331 | 370 | ||
| 371 | + | func (a *App) LoadInitialShell(ctx context.Context) { | |
| 372 | + | if a == nil { | |
| 373 | + | return | |
| 374 | + | } | |
| 375 | + | a.loadShellTheme(ctx) | |
| 376 | + | if a.currentRoute == "" || a.currentRoute == RouteUnknown { | |
| 377 | + | a.currentRoute = ParseRoute(a.deps.LocationURI) | |
| 378 | + | } | |
| 379 | + | switch a.currentRoute { | |
| 380 | + | case RouteHistory: | |
| 381 | + | a.loadHistoryLoadingRoute() | |
| 382 | + | default: | |
| 383 | + | a.resetRouteState() | |
| 384 | + | a.renderedHTML = renderAppFrame(renderedPage{ | |
| 385 | + | route: a.currentRoute, | |
| 386 | + | title: routeDocumentTitle(a.currentRoute), | |
| 387 | + | subtitle: routeSubtitle(a.currentRoute), | |
| 388 | + | body: placeholderHTML(a.currentRoute), | |
| 389 | + | }, a.shellTheme) | |
| 390 | + | } | |
| 391 | + | } | |
| 392 | + | ||
| 332 | 393 | func (a *App) LoadInitial(ctx context.Context) error { | |
| 333 | 394 | if a == nil { | |
| 334 | 395 | return errors.New("app is nil") | |
| @@ -338,14 +399,20 @@ func (a *App) LoadInitial(ctx context.Context) error { | |||
| 338 | 399 | a.currentRoute = ParseRoute(a.deps.LocationURI) | |
| 339 | 400 | } | |
| 340 | 401 | ||
| 341 | - | var err error | |
| 402 | + | if err := a.loadCurrentRoute(ctx); err != nil { | |
| 403 | + | a.renderRouteError(err) | |
| 404 | + | } | |
| 405 | + | return nil | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | func (a *App) loadCurrentRoute(ctx context.Context) error { | |
| 342 | 409 | switch a.currentRoute { | |
| 343 | 410 | case RouteHistory: | |
| 344 | - | err = a.loadHistoryRoute(ctx) | |
| 411 | + | return a.loadHistoryRoute(ctx) | |
| 345 | 412 | case RouteFavorites: | |
| 346 | - | err = a.loadFavoritesRoute(ctx) | |
| 413 | + | return a.loadFavoritesRoute(ctx) | |
| 347 | 414 | case RouteConfig: | |
| 348 | - | err = a.loadConfigRoute(ctx) | |
| 415 | + | return a.loadConfigRoute(ctx) | |
| 349 | 416 | default: | |
| 350 | 417 | a.resetRouteState() | |
| 351 | 418 | a.renderedHTML = renderAppFrame(renderedPage{ | |
| @@ -356,10 +423,6 @@ func (a *App) LoadInitial(ctx context.Context) error { | |||
| 356 | 423 | }, a.shellTheme) | |
| 357 | 424 | return nil | |
| 358 | 425 | } | |
| 359 | - | if err != nil { | |
| 360 | - | a.renderRouteError(err) | |
| 361 | - | } | |
| 362 | - | return nil | |
| 363 | 426 | } | |
| 364 | 427 | ||
| 365 | 428 | func (a *App) renderRouteError(err error) { | |
| @@ -402,6 +465,36 @@ func routeSubtitle(route Route) string { | |||
| 402 | 465 | } | |
| 403 | 466 | } | |
| 404 | 467 | ||
| 468 | + | func (a *App) loadHistoryLoadingRoute() { | |
| 469 | + | a.favorites = nil | |
| 470 | + | a.folders = nil | |
| 471 | + | a.tags = nil | |
| 472 | + | a.historyEntries = nil | |
| 473 | + | a.historyStats = nil | |
| 474 | + | a.historyAnalytics = nil | |
| 475 | + | a.historyDomainStats = nil | |
| 476 | + | a.historyNotice = "" | |
| 477 | + | a.historyError = "" | |
| 478 | + | data := historyRenderData{ | |
| 479 | + | Query: a.historyQuery, | |
| 480 | + | DomainFilter: a.historyDomainFilter, | |
| 481 | + | Offset: a.historyOffset, | |
| 482 | + | Limit: historyTimelineLimit, | |
| 483 | + | WindowBefore: a.historyWindowBefore, | |
| 484 | + | WindowAfter: a.historyWindowAfter, | |
| 485 | + | HasMore: a.historyHasMore, | |
| 486 | + | Notice: a.historyNotice, | |
| 487 | + | Error: a.historyError, | |
| 488 | + | Loading: true, | |
| 489 | + | } | |
| 490 | + | a.renderedHTML = renderAppFrame(renderedPage{ | |
| 491 | + | route: RouteHistory, | |
| 492 | + | title: "History — Loading", | |
| 493 | + | subtitle: "Recent visits", | |
| 494 | + | body: historyHTML(data), | |
| 495 | + | }, a.shellTheme) | |
| 496 | + | } | |
| 497 | + | ||
| 405 | 498 | func (a *App) loadHistoryRoute(ctx context.Context) error { | |
| 406 | 499 | if a.deps.History == nil { | |
| 407 | 500 | a.resetRouteState() | |
| @@ -414,13 +507,13 @@ func (a *App) loadHistoryRoute(ctx context.Context) error { | |||
| 414 | 507 | return nil | |
| 415 | 508 | } | |
| 416 | 509 | ||
| 417 | - | entries, err := a.loadHistoryEntries(ctx) | |
| 510 | + | window, entries, err := a.loadHistoryWindow(ctx) | |
| 418 | 511 | if err != nil { | |
| 419 | 512 | return err | |
| 420 | 513 | } | |
| 421 | - | analytics, analyticsErr := a.deps.History.Analytics(ctx) | |
| 422 | - | if analyticsErr != nil { | |
| 423 | - | analytics = nil | |
| 514 | + | stats, err := a.deps.History.Stats(ctx) | |
| 515 | + | if err != nil { | |
| 516 | + | return fmt.Errorf("failed to load history stats: %w", err) | |
| 424 | 517 | } | |
| 425 | 518 | domains, domainsErr := a.deps.History.DomainStats(ctx, 10) | |
| 426 | 519 | if domainsErr != nil { | |
| @@ -430,16 +523,30 @@ func (a *App) loadHistoryRoute(ctx context.Context) error { | |||
| 430 | 523 | a.folders = nil | |
| 431 | 524 | a.tags = nil | |
| 432 | 525 | a.historyEntries = entries | |
| 433 | - | a.historyAnalytics = analytics | |
| 526 | + | a.historyStats = stats | |
| 527 | + | a.historyAnalytics = nil | |
| 434 | 528 | a.historyDomainStats = domains | |
| 529 | + | if window != nil { | |
| 530 | + | a.historyWindowBefore = window.Before | |
| 531 | + | a.historyWindowAfter = window.After | |
| 532 | + | a.historyHasMore = window.HasMore | |
| 533 | + | } else { | |
| 534 | + | a.historyWindowBefore = time.Time{} | |
| 535 | + | a.historyWindowAfter = time.Time{} | |
| 536 | + | a.historyHasMore = false | |
| 537 | + | } | |
| 435 | 538 | data := historyRenderData{ | |
| 436 | 539 | Entries: entries, | |
| 437 | - | Analytics: analytics, | |
| 540 | + | Stats: stats, | |
| 541 | + | Analytics: nil, | |
| 438 | 542 | Domains: domains, | |
| 439 | 543 | Query: a.historyQuery, | |
| 440 | 544 | DomainFilter: a.historyDomainFilter, | |
| 441 | 545 | Offset: a.historyOffset, | |
| 442 | 546 | Limit: historyTimelineLimit, | |
| 547 | + | WindowBefore: a.historyWindowBefore, | |
| 548 | + | WindowAfter: a.historyWindowAfter, | |
| 549 | + | HasMore: a.historyHasMore, | |
| 443 | 550 | Notice: a.historyNotice, | |
| 444 | 551 | Error: a.historyError, | |
| 445 | 552 | } | |
| @@ -452,16 +559,21 @@ func (a *App) loadHistoryRoute(ctx context.Context) error { | |||
| 452 | 559 | return nil | |
| 453 | 560 | } | |
| 454 | 561 | ||
| 455 | - | func (a *App) loadHistoryEntries(ctx context.Context) ([]*entity.HistoryEntry, error) { | |
| 562 | + | func (a *App) loadHistoryWindow(ctx context.Context) (*entity.HistoryWindow, []*entity.HistoryEntry, error) { | |
| 456 | 563 | query := strings.TrimSpace(a.historyQuery) | |
| 457 | 564 | domain := strings.TrimSpace(a.historyDomainFilter) | |
| 458 | 565 | if query != "" { | |
| 459 | - | return a.deps.History.Search(ctx, query, historyTimelineLimit) | |
| 566 | + | entries, err := a.deps.History.Search(ctx, query, historySearchLimit) | |
| 567 | + | return nil, entries, err | |
| 568 | + | } | |
| 569 | + | window, err := a.deps.History.TimelineWindow(ctx, a.historyWindowBefore, domain) | |
| 570 | + | if err != nil { | |
| 571 | + | return nil, nil, err | |
| 460 | 572 | } | |
| 461 | - | if domain != "" { | |
| 462 | - | return a.deps.History.TimelineByDomain(ctx, domain, historyTimelineLimit, a.historyOffset) | |
| 573 | + | if window == nil { | |
| 574 | + | return nil, nil, nil | |
| 463 | 575 | } | |
| 464 | - | return a.deps.History.Timeline(ctx, historyTimelineLimit, a.historyOffset) | |
| 576 | + | return window, window.Entries, nil | |
| 465 | 577 | } | |
| 466 | 578 | ||
| 467 | 579 | func (a *App) loadFavoritesRoute(ctx context.Context) error { | |
| @@ -571,11 +683,15 @@ func (a *App) loadShellTheme(ctx context.Context) { | |||
| 571 | 683 | ||
| 572 | 684 | func (a *App) resetRouteState() { | |
| 573 | 685 | a.historyEntries = nil | |
| 686 | + | a.historyStats = nil | |
| 574 | 687 | a.historyAnalytics = nil | |
| 575 | 688 | a.historyDomainStats = nil | |
| 576 | 689 | a.historyQuery = "" | |
| 577 | 690 | a.historyDomainFilter = "" | |
| 578 | 691 | a.historyOffset = 0 | |
| 692 | + | a.historyWindowBefore = time.Time{} | |
| 693 | + | a.historyWindowAfter = time.Time{} | |
| 694 | + | a.historyHasMore = false | |
| 579 | 695 | a.historyNotice = "" | |
| 580 | 696 | a.historyError = "" | |
| 581 | 697 | a.favorites = nil | |
M internal/ui/systemviews/app_test.go +206 -9
| @@ -3,7 +3,9 @@ package systemviews | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | + | "sync" | |
| 6 | 7 | "testing" | |
| 8 | + | "time" | |
| 7 | 9 | ||
| 8 | 10 | "github.com/bnema/dumber/internal/application/dto" | |
| 9 | 11 | "github.com/bnema/dumber/internal/application/port" | |
| @@ -78,7 +80,8 @@ func TestAppLoadInitialHistoryRouteUsesStyledSections(t *testing.T) { | |||
| 78 | 80 | assert.Contains(t, app.renderedHTML, "Example") | |
| 79 | 81 | assert.Contains(t, app.renderedHTML, "https://example.com") | |
| 80 | 82 | assert.True(t, history.called) | |
| 81 | - | assert.Equal(t, 25, history.limit) | |
| 83 | + | assert.False(t, history.analyticsCalled) | |
| 84 | + | assert.Equal(t, 0, history.limit) | |
| 82 | 85 | assert.Equal(t, 0, history.offset) | |
| 83 | 86 | ||
| 84 | 87 | // Shell frame present, no full document wrapper. | |
| @@ -92,6 +95,129 @@ func TestAppLoadInitialHistoryRouteUsesStyledSections(t *testing.T) { | |||
| 92 | 95 | assert.Contains(t, app.renderedHTML, `class="sv-list"`) | |
| 93 | 96 | } | |
| 94 | 97 | ||
| 98 | + | func TestAppLoadInitialHistoryRouteShowsTotalStatsWithWindowedTimeline(t *testing.T) { | |
| 99 | + | t.Parallel() | |
| 100 | + | ||
| 101 | + | history := &fakeHistoryService{ | |
| 102 | + | entries: []*entity.HistoryEntry{ | |
| 103 | + | {ID: 1, URL: "https://older.example", Title: "Older", VisitCount: 2}, | |
| 104 | + | {ID: 2, URL: "https://newer.example", Title: "Newer", VisitCount: 3}, | |
| 105 | + | }, | |
| 106 | + | stats: &entity.HistoryStats{TotalEntries: 8904, TotalVisits: 40947, UniqueDays: 121}, | |
| 107 | + | } | |
| 108 | + | app := NewApp(Dependencies{History: history, LocationURI: "dumb://history"}) | |
| 109 | + | ||
| 110 | + | require.NoError(t, app.LoadInitial(context.Background())) | |
| 111 | + | assert.True(t, history.statsCalled) | |
| 112 | + | assert.Contains(t, app.renderedHTML, "Loaded 2 items") | |
| 113 | + | assert.Contains(t, app.renderedHTML, `data-page-title="History — 8904 entries"`) | |
| 114 | + | assert.Contains(t, app.renderedHTML, `<span class="sv-stat-value">8904</span><span class="sv-stat-label">Entries</span>`) | |
| 115 | + | assert.Contains(t, app.renderedHTML, `<span class="sv-stat-value">40947</span><span class="sv-stat-label">Visits</span>`) | |
| 116 | + | assert.Contains(t, app.renderedHTML, `<span class="sv-stat-value">121</span><span class="sv-stat-label">Days</span>`) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func TestAppRunMountsHistoryLoadingBeforeAsyncHydration(t *testing.T) { | |
| 120 | + | t.Parallel() | |
| 121 | + | ||
| 122 | + | dom := &fakeActionDOM{fakeDOM: fakeDOM{mounts: make(chan string, 4)}} | |
| 123 | + | history := &fakeHistoryService{ | |
| 124 | + | entries: []*entity.HistoryEntry{{ID: 1, URL: "https://example.com", Title: "Loaded entry"}}, | |
| 125 | + | timelineStarted: make(chan struct{}), | |
| 126 | + | releaseTimeline: make(chan struct{}), | |
| 127 | + | } | |
| 128 | + | app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"}) | |
| 129 | + | ||
| 130 | + | require.NoError(t, app.RunWithContext(context.Background())) | |
| 131 | + | loadingHTML := receiveMount(t, dom.mounts) | |
| 132 | + | assert.Contains(t, loadingHTML, "Loading history") | |
| 133 | + | assert.NotContains(t, loadingHTML, "Loaded entry") | |
| 134 | + | ||
| 135 | + | select { | |
| 136 | + | case <-history.timelineStarted: | |
| 137 | + | case <-time.After(time.Second): | |
| 138 | + | t.Fatal("history timeline was not started asynchronously") | |
| 139 | + | } | |
| 140 | + | close(history.releaseTimeline) | |
| 141 | + | ||
| 142 | + | hydratedHTML := receiveMount(t, dom.mounts) | |
| 143 | + | assert.Contains(t, hydratedHTML, "Loaded entry") | |
| 144 | + | assert.Contains(t, hydratedHTML, "Loaded 1 item") | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func TestAppHandleHistoryLoadMoreAppendsOlderWindow(t *testing.T) { | |
| 148 | + | t.Parallel() | |
| 149 | + | ||
| 150 | + | cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC) | |
| 151 | + | history := &fakeHistoryService{window: &entity.HistoryWindow{ | |
| 152 | + | Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example", Title: "Older entry", LastVisited: cursor.Add(-time.Hour)}}, | |
| 153 | + | Before: cursor, | |
| 154 | + | After: cursor.Add(-24 * time.Hour), | |
| 155 | + | HasMore: true, | |
| 156 | + | }} | |
| 157 | + | dom := &fakeActionDOM{} | |
| 158 | + | app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"}) | |
| 159 | + | app.currentRoute = RouteHistory | |
| 160 | + | app.historyEntries = []*entity.HistoryEntry{{ID: 1, URL: "https://newer.example", Title: "Newer entry", LastVisited: cursor}} | |
| 161 | + | app.historyWindowAfter = cursor | |
| 162 | + | app.historyHasMore = true | |
| 163 | + | ||
| 164 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| 165 | + | Action: historyActionLoadMore, | |
| 166 | + | Data: map[string]string{"before": cursor.Format(time.RFC3339Nano)}, | |
| 167 | + | })) | |
| 168 | + | assert.Equal(t, cursor, history.windowBefore) | |
| 169 | + | assert.Len(t, app.historyEntries, 2) | |
| 170 | + | assert.Contains(t, dom.appendedHTML, "Older entry") | |
| 171 | + | assert.Contains(t, dom.appendedHTML, `data-sv-action="history.loadMore"`) | |
| 172 | + | assert.Empty(t, dom.html, "load-more should append a fragment instead of remounting the whole page") | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func TestAppHandleHistoryLoadMoreRemountsWhenAppendUnavailable(t *testing.T) { | |
| 176 | + | t.Parallel() | |
| 177 | + | ||
| 178 | + | cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC) | |
| 179 | + | history := &fakeHistoryService{window: &entity.HistoryWindow{ | |
| 180 | + | Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example", Title: "Older entry", LastVisited: cursor.Add(-time.Hour)}}, | |
| 181 | + | Before: cursor, | |
| 182 | + | After: cursor.Add(-24 * time.Hour), | |
| 183 | + | HasMore: true, | |
| 184 | + | }} | |
| 185 | + | dom := &fakeDOM{} | |
| 186 | + | app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"}) | |
| 187 | + | app.currentRoute = RouteHistory | |
| 188 | + | app.historyEntries = []*entity.HistoryEntry{{ID: 1, URL: "https://newer.example", Title: "Newer entry", LastVisited: cursor}} | |
| 189 | + | app.historyWindowAfter = cursor | |
| 190 | + | app.historyHasMore = true | |
| 191 | + | ||
| 192 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| 193 | + | Action: historyActionLoadMore, | |
| 194 | + | Data: map[string]string{"before": cursor.Format(time.RFC3339Nano)}, | |
| 195 | + | })) | |
| 196 | + | assert.Equal(t, cursor, history.windowBefore) | |
| 197 | + | assert.Len(t, app.historyEntries, 2) | |
| 198 | + | assert.Contains(t, dom.html, "Older entry") | |
| 199 | + | assert.Contains(t, dom.html, `data-sv-action="history.loadMore"`) | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func TestAppHandleHistoryLoadMoreIgnoresStaleCursor(t *testing.T) { | |
| 203 | + | t.Parallel() | |
| 204 | + | ||
| 205 | + | cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC) | |
| 206 | + | history := &fakeHistoryService{window: &entity.HistoryWindow{Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example"}}}} | |
| 207 | + | dom := &fakeActionDOM{} | |
| 208 | + | app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"}) | |
| 209 | + | app.currentRoute = RouteHistory | |
| 210 | + | app.historyWindowAfter = cursor | |
| 211 | + | app.historyHasMore = true | |
| 212 | + | ||
| 213 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| 214 | + | Action: historyActionLoadMore, | |
| 215 | + | Data: map[string]string{"before": cursor.Add(-24 * time.Hour).Format(time.RFC3339Nano)}, | |
| 216 | + | })) | |
| 217 | + | assert.False(t, history.called) | |
| 218 | + | assert.Empty(t, dom.appendedHTML) | |
| 219 | + | } | |
| 220 | + | ||
| 95 | 221 | func TestAppLoadInitialHistoryRouteRendersManagementActions(t *testing.T) { | |
| 96 | 222 | t.Parallel() | |
| 97 | 223 | ||
| @@ -161,8 +287,8 @@ func TestAppHandleHistoryActionsRefreshesDOM(t *testing.T) { | |||
| 161 | 287 | Action: historyActionFilterDomain, | |
| 162 | 288 | Data: map[string]string{"domain": "example.com"}, | |
| 163 | 289 | })) | |
| 164 | - | assert.True(t, history.domainCalled) | |
| 165 | - | assert.Equal(t, "example.com", history.domain) | |
| 290 | + | assert.True(t, history.called) | |
| 291 | + | assert.Equal(t, "example.com", history.windowDomain) | |
| 166 | 292 | assert.Empty(t, app.historyQuery) | |
| 167 | 293 | ||
| 168 | 294 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| @@ -720,16 +846,39 @@ func testDefaultConfigPayload() dto.SystemviewConfigPayload { | |||
| 720 | 846 | ||
| 721 | 847 | // Handwritten fake to capture DOM mounts for stateful render assertions. | |
| 722 | 848 | type fakeDOM struct { | |
| 723 | - | mounted bool | |
| 724 | - | html string | |
| 849 | + | mu sync.Mutex | |
| 850 | + | mounted bool | |
| 851 | + | html string | |
| 852 | + | appendedHTML string | |
| 853 | + | mounts chan string | |
| 725 | 854 | } | |
| 726 | 855 | ||
| 727 | 856 | func (d *fakeDOM) Mount(markup string) error { | |
| 857 | + | d.mu.Lock() | |
| 728 | 858 | d.mounted = true | |
| 729 | 859 | d.html = markup | |
| 860 | + | mounts := d.mounts | |
| 861 | + | d.mu.Unlock() | |
| 862 | + | if mounts != nil { | |
| 863 | + | select { | |
| 864 | + | case mounts <- markup: | |
| 865 | + | default: | |
| 866 | + | } | |
| 867 | + | } | |
| 730 | 868 | return nil | |
| 731 | 869 | } | |
| 732 | 870 | ||
| 871 | + | func receiveMount(t *testing.T, mounts <-chan string) string { | |
| 872 | + | t.Helper() | |
| 873 | + | select { | |
| 874 | + | case html := <-mounts: | |
| 875 | + | return html | |
| 876 | + | case <-time.After(time.Second): | |
| 877 | + | t.Fatal("timed out waiting for DOM mount") | |
| 878 | + | return "" | |
| 879 | + | } | |
| 880 | + | } | |
| 881 | + | ||
| 733 | 882 | type fakeActionDOM struct { | |
| 734 | 883 | fakeDOM | |
| 735 | 884 | handler DOMActionHandler | |
| @@ -741,6 +890,13 @@ func (d *fakeActionDOM) BindActions(handler DOMActionHandler) error { | |||
| 741 | 890 | return nil | |
| 742 | 891 | } | |
| 743 | 892 | ||
| 893 | + | func (d *fakeActionDOM) AppendHistoryTimeline(markup string) error { | |
| 894 | + | d.mu.Lock() | |
| 895 | + | d.appendedHTML += markup | |
| 896 | + | d.mu.Unlock() | |
| 897 | + | return nil | |
| 898 | + | } | |
| 899 | + | ||
| 744 | 900 | func (d *fakeActionDOM) Release() { | |
| 745 | 901 | d.released = true | |
| 746 | 902 | } | |
| @@ -769,16 +925,32 @@ type fakeHistoryService struct { | |||
| 769 | 925 | searchLimit int | |
| 770 | 926 | searchEntries []*entity.HistoryEntry | |
| 771 | 927 | ||
| 772 | - | deletedEntryID int64 | |
| 773 | - | deletedRangeID string | |
| 774 | - | deletedDomain string | |
| 775 | - | domainStats []*entity.DomainStat | |
| 928 | + | deletedEntryID int64 | |
| 929 | + | deletedRangeID string | |
| 930 | + | deletedDomain string | |
| 931 | + | domainStats []*entity.DomainStat | |
| 932 | + | stats *entity.HistoryStats | |
| 933 | + | statsCalled bool | |
| 934 | + | window *entity.HistoryWindow | |
| 935 | + | windowBefore time.Time | |
| 936 | + | windowDomain string | |
| 937 | + | analyticsCalled bool | |
| 938 | + | ||
| 939 | + | timelineStarted chan struct{} | |
| 940 | + | releaseTimeline chan struct{} | |
| 941 | + | startOnce sync.Once | |
| 776 | 942 | } | |
| 777 | 943 | ||
| 778 | 944 | func (s *fakeHistoryService) Timeline(_ context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { | |
| 779 | 945 | s.called = true | |
| 780 | 946 | s.limit = limit | |
| 781 | 947 | s.offset = offset | |
| 948 | + | if s.timelineStarted != nil { | |
| 949 | + | s.startOnce.Do(func() { close(s.timelineStarted) }) | |
| 950 | + | } | |
| 951 | + | if s.releaseTimeline != nil { | |
| 952 | + | <-s.releaseTimeline | |
| 953 | + | } | |
| 782 | 954 | return s.entries, s.err | |
| 783 | 955 | } | |
| 784 | 956 | ||
| @@ -790,6 +962,25 @@ func (s *fakeHistoryService) TimelineByDomain(_ context.Context, domain string, | |||
| 790 | 962 | return s.entries, s.err | |
| 791 | 963 | } | |
| 792 | 964 | ||
| 965 | + | func (s *fakeHistoryService) TimelineWindow(_ context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) { | |
| 966 | + | s.called = true | |
| 967 | + | s.windowBefore = before | |
| 968 | + | s.windowDomain = domain | |
| 969 | + | if s.timelineStarted != nil { | |
| 970 | + | s.startOnce.Do(func() { close(s.timelineStarted) }) | |
| 971 | + | } | |
| 972 | + | if s.releaseTimeline != nil { | |
| 973 | + | <-s.releaseTimeline | |
| 974 | + | } | |
| 975 | + | if s.err != nil { | |
| 976 | + | return nil, s.err | |
| 977 | + | } | |
| 978 | + | if s.window != nil { | |
| 979 | + | return s.window, nil | |
| 980 | + | } | |
| 981 | + | return &entity.HistoryWindow{Entries: s.entries, Before: before, After: before.Add(-24 * time.Hour)}, nil | |
| 982 | + | } | |
| 983 | + | ||
| 793 | 984 | func (s *fakeHistoryService) Search(_ context.Context, query string, limit int) ([]*entity.HistoryEntry, error) { | |
| 794 | 985 | s.searchCalled = true | |
| 795 | 986 | s.query = query | |
| @@ -810,7 +1001,13 @@ func (s *fakeHistoryService) DeleteRange(_ context.Context, rangeID string) erro | |||
| 810 | 1001 | return nil | |
| 811 | 1002 | } | |
| 812 | 1003 | ||
| 1004 | + | func (s *fakeHistoryService) Stats(context.Context) (*entity.HistoryStats, error) { | |
| 1005 | + | s.statsCalled = true | |
| 1006 | + | return s.stats, nil | |
| 1007 | + | } | |
| 1008 | + | ||
| 813 | 1009 | func (s *fakeHistoryService) Analytics(context.Context) (*entity.HistoryAnalytics, error) { | |
| 1010 | + | s.analyticsCalled = true | |
| 814 | 1011 | return nil, nil | |
| 815 | 1012 | } | |
| 816 | 1013 | ||
M internal/ui/systemviews/components.templ +14 -0
| @@ -32,6 +32,20 @@ templ EmptyState(message string) { | |||
| 32 | 32 | <div class="sv-empty"><p>{ message }</p></div> | |
| 33 | 33 | } | |
| 34 | 34 | ||
| 35 | + | templ LoadingPanel(message string) { | |
| 36 | + | <div class="sv-loading-panel" role="status" aria-live="polite"> | |
| 37 | + | <span class="sv-spinner" aria-hidden="true"></span> | |
| 38 | + | <span>{ message }</span> | |
| 39 | + | </div> | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | templ LoadingCard(label string) { | |
| 43 | + | <div class="sv-stat-card sv-loading-card" role="status" aria-live="polite"> | |
| 44 | + | <span class="sv-spinner" aria-hidden="true"></span> | |
| 45 | + | <span class="sv-stat-label">{ label }</span> | |
| 46 | + | </div> | |
| 47 | + | } | |
| 48 | + | ||
| 35 | 49 | templ Alert(kind string, message string) { | |
| 36 | 50 | if message != "" { | |
| 37 | 51 | <div class={ alertClass(kind) } role={ alertRole(kind) }>{ message }</div> | |
M internal/ui/systemviews/components_templ.go +162 -78 generated file: collapsed
M internal/ui/systemviews/config.templ +10 -10
| @@ -41,8 +41,8 @@ templ AppearanceForm(config dto.SystemviewConfigPayload) { | |||
| 41 | 41 | @PaletteFields("Light palette", "light", config.Appearance.LightPalette) | |
| 42 | 42 | @PaletteFields("Dark palette", "dark", config.Appearance.DarkPalette) | |
| 43 | 43 | <div class="sv-button-row sv-form-actions"> | |
| 44 | - | <button class="sv-button" type="submit">Save appearance</button> | |
| 45 | - | <button class="sv-button sv-button-secondary" type="button" data-sv-action="config.appearance.reset" data-sv-confirm="Reset appearance settings to defaults?">Reset appearance</button> | |
| 44 | + | <button class="sv-button" type="submit">@ButtonIconLabel(iconSave, "Save appearance")</button> | |
| 45 | + | <button class="sv-button sv-button-secondary" type="button" data-sv-action="config.appearance.reset" data-sv-confirm="Reset appearance settings to defaults?">@ButtonIconLabel(iconRotateCCW, "Reset appearance")</button> | |
| 46 | 46 | </div> | |
| 47 | 47 | </form> | |
| 48 | 48 | } | |
| @@ -63,7 +63,7 @@ templ PaletteFields(title string, prefix string, palette dto.ColorPalette) { | |||
| 63 | 63 | templ SearchSettings(config dto.SystemviewConfigPayload) { | |
| 64 | 64 | <form class="sv-card-form" data-sv-action="config.search.save"> | |
| 65 | 65 | <label><span>Default search engine</span><input name="default_search_engine" type="text" value={ config.DefaultSearchEngine } placeholder="https://duckduckgo.com/?q=%s" required/></label> | |
| 66 | - | <button class="sv-button" type="submit">Save search default</button> | |
| 66 | + | <button class="sv-button" type="submit">@ButtonIconLabel(iconSave, "Save search default")</button> | |
| 67 | 67 | </form> | |
| 68 | 68 | <div class="sv-group"> | |
| 69 | 69 | <div class="sv-group-header"><h4>Search shortcuts</h4></div> | |
| @@ -72,7 +72,7 @@ templ SearchSettings(config dto.SystemviewConfigPayload) { | |||
| 72 | 72 | <label><span>Key</span><input name="key" type="text" placeholder="ddg" required/></label> | |
| 73 | 73 | <label><span>URL</span><input name="url" type="text" placeholder="https://duckduckgo.com/?q=%s" pattern=".*%s.*" required/></label> | |
| 74 | 74 | <label><span>Description</span><input name="description" type="text" placeholder="DuckDuckGo"/></label> | |
| 75 | - | <button class="sv-button sv-button-secondary" type="submit">Add shortcut</button> | |
| 75 | + | <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconPlus, "Add shortcut")</button> | |
| 76 | 76 | </form> | |
| 77 | 77 | {{ shortcuts := searchShortcutRows(config.SearchShortcuts) }} | |
| 78 | 78 | if len(shortcuts) == 0 { | |
| @@ -94,8 +94,8 @@ templ SearchShortcutItem(shortcut searchShortcutRow) { | |||
| 94 | 94 | <label><span>Key</span><input name="new_key" type="text" value={ shortcut.Key } required/></label> | |
| 95 | 95 | <label><span>URL</span><input name="url" type="text" value={ shortcut.URL } pattern=".*%s.*" required/></label> | |
| 96 | 96 | <label><span>Description</span><input name="description" type="text" value={ shortcut.Description }/></label> | |
| 97 | - | <button class="sv-button sv-button-secondary" type="submit">Save</button> | |
| 98 | - | <button class="sv-button sv-button-secondary sv-button-danger" type="button" data-sv-action="config.searchShortcut.delete" data-key={ shortcut.Key } data-sv-confirm="Delete this search shortcut?">Delete</button> | |
| 97 | + | <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconSave, "Save")</button> | |
| 98 | + | <button class="sv-button sv-button-secondary sv-button-danger sv-button-icon-only" type="button" data-sv-action="config.searchShortcut.delete" data-key={ shortcut.Key } data-sv-confirm="Delete this search shortcut?" aria-label={ "Delete search shortcut " + shortcut.Key }>@IconOnly(iconTrash, "Delete search shortcut " + shortcut.Key)</button> | |
| 99 | 99 | </form> | |
| 100 | 100 | </li> | |
| 101 | 101 | } | |
| @@ -115,7 +115,7 @@ templ PerformanceSettings(config dto.SystemviewConfigPayload) { | |||
| 115 | 115 | <label><span>Web process memory MB</span><input name="web_process_memory_mb" type="number" min="0" max="16384" value={ formatConfigInt(config.Performance.Custom.WebProcessMemoryMB) } disabled?={ !customProfile }/></label> | |
| 116 | 116 | <label><span>Network process memory MB</span><input name="network_process_memory_mb" type="number" min="0" max="4096" value={ formatConfigInt(config.Performance.Custom.NetworkProcessMemoryMB) } disabled?={ !customProfile }/></label> | |
| 117 | 117 | <label><span>WebView pool prewarm</span><input name="webview_pool_prewarm" type="number" min="0" max="20" value={ formatConfigInt(config.Performance.Custom.WebViewPoolPrewarm) } disabled?={ !customProfile }/></label> | |
| 118 | - | <button class="sv-button" type="submit">Save performance</button> | |
| 118 | + | <button class="sv-button" type="submit">@ButtonIconLabel(iconSave, "Save performance")</button> | |
| 119 | 119 | </form> | |
| 120 | 120 | <div class="sv-stat-grid"> | |
| 121 | 121 | <div class="sv-stat-card"><span class="sv-stat-value">{ formatConfigInt(config.Performance.Hardware.CPUThreads) }</span><span class="sv-stat-label">CPU threads</span></div> | |
| @@ -138,7 +138,7 @@ templ ConfigKeybindings(keybindings port.KeybindingsConfig) { | |||
| 138 | 138 | @EmptyState("No keybindings configured") | |
| 139 | 139 | } else { | |
| 140 | 140 | <div class="sv-button-row"> | |
| 141 | - | <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> | |
| 141 | + | <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?">@ButtonIconLabel(iconRotateCCW, "Reset all keybindings")</button> | |
| 142 | 142 | </div> | |
| 143 | 143 | for _, group := range groups { | |
| 144 | 144 | @ConfigKeybindingGroup(group) | |
| @@ -189,8 +189,8 @@ templ ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding | |||
| 189 | 189 | <input type="hidden" name="mode" value={ group.Mode }/> | |
| 190 | 190 | <input type="hidden" name="action" value={ binding.Action }/> | |
| 191 | 191 | <label><span>Keys</span><input name="keys" type="text" value={ keybindingKeysValue(binding.Keys) } placeholder="ctrl+h, ctrl+shift+h" required/></label> | |
| 192 | - | <button class="sv-button sv-button-secondary" type="submit">Save</button> | |
| 193 | - | <button class="sv-button sv-button-secondary" type="button" data-sv-action="config.keybinding.reset" data-mode={ group.Mode } data-action={ binding.Action }>Reset</button> | |
| 192 | + | <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconSave, "Save")</button> | |
| 193 | + | <button class="sv-button sv-button-secondary" type="button" data-sv-action="config.keybinding.reset" data-mode={ group.Mode } data-action={ binding.Action }>@ButtonIconLabel(iconRotateCCW, "Reset")</button> | |
| 194 | 194 | </form> | |
| 195 | 195 | </div> | |
| 196 | 196 | </li> | |
M internal/ui/systemviews/config_templ.go +242 -149 generated file: collapsed
M internal/ui/systemviews/dom.go +6 -0
| @@ -24,6 +24,12 @@ type DOMActionBinder interface { | |||
| 24 | 24 | BindActions(handler DOMActionHandler) error | |
| 25 | 25 | } | |
| 26 | 26 | ||
| 27 | + | // DOMHistoryTimelineAppender is implemented by DOM adapters that can append | |
| 28 | + | // older history windows without reparsing and remounting the whole page. | |
| 29 | + | type DOMHistoryTimelineAppender interface { | |
| 30 | + | AppendHistoryTimeline(markup string) error | |
| 31 | + | } | |
| 32 | + | ||
| 27 | 33 | func placeholderHTML(route Route) string { | |
| 28 | 34 | name := html.EscapeString(string(route)) | |
| 29 | 35 | if name == "" || route == RouteUnknown { | |
M internal/ui/systemviews/dom_js.go +283 -18
| @@ -10,9 +10,13 @@ import ( | |||
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | type browserDOM struct { | |
| 13 | - | document js.Value | |
| 14 | - | target js.Value | |
| 15 | - | bindings []jsEventBinding | |
| 13 | + | document js.Value | |
| 14 | + | target js.Value | |
| 15 | + | bindings []jsEventBinding | |
| 16 | + | historyObserver js.Value | |
| 17 | + | historyObserverCallback js.Func | |
| 18 | + | activeConfirmCleanup func() | |
| 19 | + | activeConfirmID uint64 | |
| 16 | 20 | } | |
| 17 | 21 | ||
| 18 | 22 | type jsEventBinding struct { | |
| @@ -38,9 +42,64 @@ func (d *browserDOM) Mount(markup string) error { | |||
| 38 | 42 | d.target.Set("innerHTML", markup) | |
| 39 | 43 | d.updateDocumentTitle() | |
| 40 | 44 | d.scheduleAlertDismissal() | |
| 45 | + | d.setupHistoryInfiniteScroll() | |
| 41 | 46 | return nil | |
| 42 | 47 | } | |
| 43 | 48 | ||
| 49 | + | func (d *browserDOM) AppendHistoryTimeline(markup string) error { | |
| 50 | + | if d == nil || !d.target.Truthy() { | |
| 51 | + | return fmt.Errorf("DOM mount target #app not found") | |
| 52 | + | } | |
| 53 | + | timeline := d.target.Call("querySelector", "[data-sv-history-timeline]") | |
| 54 | + | if !timeline.Truthy() { | |
| 55 | + | return fmt.Errorf("history timeline target not found") | |
| 56 | + | } | |
| 57 | + | if old := timeline.Call("querySelector", "[data-sv-history-load-more-container]"); old.Truthy() && old.Get("remove").Truthy() { | |
| 58 | + | old.Call("remove") | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | container := d.document.Call("createElement", "div") | |
| 62 | + | container.Set("innerHTML", markup) | |
| 63 | + | d.mergeLeadingHistoryGroup(timeline, container) | |
| 64 | + | for container.Get("firstChild").Truthy() { | |
| 65 | + | timeline.Call("appendChild", container.Get("firstChild")) | |
| 66 | + | } | |
| 67 | + | d.setupHistoryInfiniteScroll() | |
| 68 | + | return nil | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func (d *browserDOM) mergeLeadingHistoryGroup(timeline, container js.Value) { | |
| 72 | + | if !timeline.Truthy() || !container.Truthy() { | |
| 73 | + | return | |
| 74 | + | } | |
| 75 | + | first := container.Get("firstElementChild") | |
| 76 | + | if !first.Truthy() || !first.Get("hasAttribute").Truthy() || !first.Call("hasAttribute", "data-sv-history-merge-date").Bool() { | |
| 77 | + | return | |
| 78 | + | } | |
| 79 | + | date := strings.TrimSpace(first.Call("getAttribute", "data-sv-history-merge-date").String()) | |
| 80 | + | if date == "" { | |
| 81 | + | return | |
| 82 | + | } | |
| 83 | + | groups := timeline.Call("querySelectorAll", "[data-sv-history-date]") | |
| 84 | + | if !groups.Truthy() || groups.Get("length").Int() == 0 { | |
| 85 | + | return | |
| 86 | + | } | |
| 87 | + | lastGroup := groups.Index(groups.Get("length").Int() - 1) | |
| 88 | + | if strings.TrimSpace(lastGroup.Call("getAttribute", "data-sv-history-date").String()) != date { | |
| 89 | + | return | |
| 90 | + | } | |
| 91 | + | list := lastGroup.Call("querySelector", "ul") | |
| 92 | + | if !list.Truthy() { | |
| 93 | + | return | |
| 94 | + | } | |
| 95 | + | for first.Get("firstChild").Truthy() { | |
| 96 | + | list.Call("appendChild", first.Get("firstChild")) | |
| 97 | + | } | |
| 98 | + | if first.Get("remove").Truthy() { | |
| 99 | + | first.Call("remove") | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | ||
| 44 | 103 | func (d *browserDOM) updateDocumentTitle() { | |
| 45 | 104 | if d == nil || !d.document.Truthy() || !d.target.Truthy() { | |
| 46 | 105 | return | |
| @@ -129,11 +188,11 @@ func (d *browserDOM) BindActions(handler DOMActionHandler) error { | |||
| 129 | 188 | if action == "" { | |
| 130 | 189 | return nil | |
| 131 | 190 | } | |
| 132 | - | if !confirmAction(element) { | |
| 133 | - | return nil | |
| 134 | - | } | |
| 135 | 191 | event.Call("preventDefault") | |
| 136 | - | handler(DOMAction{Action: action, Data: collectActionData(element)}) | |
| 192 | + | data := collectActionData(element) | |
| 193 | + | d.confirmAction(element, func() { | |
| 194 | + | handler(DOMAction{Action: action, Data: data}) | |
| 195 | + | }) | |
| 137 | 196 | return nil | |
| 138 | 197 | }) | |
| 139 | 198 | d.addEventBinding(d.target, "click", clickHandler) | |
| @@ -151,15 +210,14 @@ func (d *browserDOM) BindActions(handler DOMActionHandler) error { | |||
| 151 | 210 | if action == "" { | |
| 152 | 211 | return nil | |
| 153 | 212 | } | |
| 154 | - | if !confirmAction(form) { | |
| 155 | - | return nil | |
| 156 | - | } | |
| 157 | 213 | event.Call("preventDefault") | |
| 158 | 214 | data := collectActionData(form) | |
| 159 | 215 | for key, value := range collectFormData(form) { | |
| 160 | 216 | data[key] = value | |
| 161 | 217 | } | |
| 162 | - | handler(DOMAction{Action: action, Data: data}) | |
| 218 | + | d.confirmAction(form, func() { | |
| 219 | + | handler(DOMAction{Action: action, Data: data}) | |
| 220 | + | }) | |
| 163 | 221 | return nil | |
| 164 | 222 | }) | |
| 165 | 223 | d.addEventBinding(d.target, "submit", submitHandler) | |
| @@ -206,10 +264,70 @@ func (d *browserDOM) addEventBinding(target js.Value, event string, fn js.Func) | |||
| 206 | 264 | d.bindings = append(d.bindings, jsEventBinding{target: target, event: event, fn: fn}) | |
| 207 | 265 | } | |
| 208 | 266 | ||
| 267 | + | func (d *browserDOM) setupHistoryInfiniteScroll() { | |
| 268 | + | if d == nil || !d.target.Truthy() { | |
| 269 | + | return | |
| 270 | + | } | |
| 271 | + | d.disconnectHistoryObserver() | |
| 272 | + | observerCtor := js.Global().Get("IntersectionObserver") | |
| 273 | + | if !observerCtor.Truthy() { | |
| 274 | + | return | |
| 275 | + | } | |
| 276 | + | button := d.target.Call("querySelector", "[data-sv-history-load-more]") | |
| 277 | + | if !button.Truthy() { | |
| 278 | + | return | |
| 279 | + | } | |
| 280 | + | callback := js.FuncOf(func(_ js.Value, args []js.Value) any { | |
| 281 | + | if len(args) == 0 || !button.Truthy() { | |
| 282 | + | return nil | |
| 283 | + | } | |
| 284 | + | entries := args[0] | |
| 285 | + | for i := 0; i < entries.Get("length").Int(); i++ { | |
| 286 | + | entry := entries.Index(i) | |
| 287 | + | if !entry.Truthy() || !entry.Get("isIntersecting").Bool() { | |
| 288 | + | continue | |
| 289 | + | } | |
| 290 | + | if button.Get("disabled").Bool() || button.Get("dataset").Get("svLoading").String() == "true" { | |
| 291 | + | return nil | |
| 292 | + | } | |
| 293 | + | // A disabled button does not dispatch click events. Dispatch first, then | |
| 294 | + | // mark the control busy so the observer cannot enqueue duplicate loads | |
| 295 | + | // while the WASM action worker fetches the next history window. | |
| 296 | + | button.Call("click") | |
| 297 | + | button.Get("dataset").Set("svLoading", "true") | |
| 298 | + | button.Set("disabled", true) | |
| 299 | + | button.Set("textContent", "Loading older visits…") | |
| 300 | + | return nil | |
| 301 | + | } | |
| 302 | + | return nil | |
| 303 | + | }) | |
| 304 | + | d.historyObserverCallback = callback | |
| 305 | + | options := js.Global().Get("Object").New() | |
| 306 | + | options.Set("rootMargin", "400px 0px") | |
| 307 | + | d.historyObserver = observerCtor.New(callback, options) | |
| 308 | + | d.historyObserver.Call("observe", button) | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | func (d *browserDOM) disconnectHistoryObserver() { | |
| 312 | + | if d == nil { | |
| 313 | + | return | |
| 314 | + | } | |
| 315 | + | if d.historyObserver.Truthy() { | |
| 316 | + | d.historyObserver.Call("disconnect") | |
| 317 | + | d.historyObserver = js.Value{} | |
| 318 | + | } | |
| 319 | + | if d.historyObserverCallback.Truthy() { | |
| 320 | + | d.historyObserverCallback.Release() | |
| 321 | + | d.historyObserverCallback = js.Func{} | |
| 322 | + | } | |
| 323 | + | } | |
| 324 | + | ||
| 209 | 325 | func (d *browserDOM) Release() { | |
| 210 | 326 | if d == nil { | |
| 211 | 327 | return | |
| 212 | 328 | } | |
| 329 | + | d.disconnectHistoryObserver() | |
| 330 | + | d.cleanupActiveConfirmDialog() | |
| 213 | 331 | for _, binding := range d.bindings { | |
| 214 | 332 | if binding.target.Truthy() { | |
| 215 | 333 | binding.target.Call("removeEventListener", binding.event, binding.fn) | |
| @@ -277,16 +395,163 @@ func collectFormData(form js.Value) map[string]string { | |||
| 277 | 395 | return data | |
| 278 | 396 | } | |
| 279 | 397 | ||
| 280 | - | func confirmAction(element js.Value) bool { | |
| 281 | - | message := element.Call("getAttribute", "data-sv-confirm").String() | |
| 398 | + | func (d *browserDOM) confirmAction(element js.Value, onConfirm func()) { | |
| 399 | + | if onConfirm == nil { | |
| 400 | + | return | |
| 401 | + | } | |
| 402 | + | confirmAttr := element.Call("getAttribute", "data-sv-confirm") | |
| 403 | + | if confirmAttr.Type() != js.TypeString { | |
| 404 | + | onConfirm() | |
| 405 | + | return | |
| 406 | + | } | |
| 407 | + | message := strings.TrimSpace(confirmAttr.String()) | |
| 282 | 408 | if message == "" { | |
| 283 | - | return true | |
| 409 | + | onConfirm() | |
| 410 | + | return | |
| 284 | 411 | } | |
| 285 | - | confirm := js.Global().Get("window").Get("confirm") | |
| 286 | - | if !confirm.Truthy() { | |
| 287 | - | return true | |
| 412 | + | if !d.showConfirmDialog(message, onConfirm) { | |
| 413 | + | onConfirm() | |
| 414 | + | } | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | func (d *browserDOM) cleanupActiveConfirmDialog() { | |
| 418 | + | if d == nil || d.activeConfirmCleanup == nil { | |
| 419 | + | return | |
| 420 | + | } | |
| 421 | + | cleanup := d.activeConfirmCleanup | |
| 422 | + | d.activeConfirmCleanup = nil | |
| 423 | + | cleanup() | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | func (d *browserDOM) showConfirmDialog(message string, onConfirm func()) bool { | |
| 427 | + | if d == nil || !d.document.Truthy() { | |
| 428 | + | return false | |
| 429 | + | } | |
| 430 | + | body := d.document.Get("body") | |
| 431 | + | if !body.Truthy() { | |
| 432 | + | return false | |
| 288 | 433 | } | |
| 289 | - | return confirm.Invoke(message).Bool() | |
| 434 | + | // Always close an existing modal through its cleanup path so key handlers and | |
| 435 | + | // js.Func values are released before the replacement is mounted. | |
| 436 | + | d.cleanupActiveConfirmDialog() | |
| 437 | + | ||
| 438 | + | overlay := d.document.Call("createElement", "div") | |
| 439 | + | overlay.Get("classList").Call("add", "sv-confirm-backdrop") | |
| 440 | + | overlay.Set("role", "presentation") | |
| 441 | + | ||
| 442 | + | dialog := d.document.Call("createElement", "div") | |
| 443 | + | dialog.Get("classList").Call("add", "sv-confirm-dialog") | |
| 444 | + | dialog.Set("role", "dialog") | |
| 445 | + | dialog.Call("setAttribute", "aria-modal", "true") | |
| 446 | + | dialog.Call("setAttribute", "aria-labelledby", "sv-confirm-title") | |
| 447 | + | dialog.Call("setAttribute", "aria-describedby", "sv-confirm-message") | |
| 448 | + | overlay.Call("appendChild", dialog) | |
| 449 | + | ||
| 450 | + | title := d.document.Call("createElement", "h3") | |
| 451 | + | title.Set("id", "sv-confirm-title") | |
| 452 | + | title.Set("textContent", "Confirm action") | |
| 453 | + | dialog.Call("appendChild", title) | |
| 454 | + | ||
| 455 | + | text := d.document.Call("createElement", "p") | |
| 456 | + | text.Set("id", "sv-confirm-message") | |
| 457 | + | text.Set("textContent", message) | |
| 458 | + | dialog.Call("appendChild", text) | |
| 459 | + | ||
| 460 | + | actions := d.document.Call("createElement", "div") | |
| 461 | + | actions.Get("classList").Call("add", "sv-confirm-actions") | |
| 462 | + | dialog.Call("appendChild", actions) | |
| 463 | + | ||
| 464 | + | cancelButton := d.document.Call("createElement", "button") | |
| 465 | + | cancelButton.Set("type", "button") | |
| 466 | + | cancelButton.Set("className", "sv-button sv-button-secondary") | |
| 467 | + | cancelButton.Set("textContent", "Cancel") | |
| 468 | + | actions.Call("appendChild", cancelButton) | |
| 469 | + | ||
| 470 | + | confirmButton := d.document.Call("createElement", "button") | |
| 471 | + | confirmButton.Set("type", "button") | |
| 472 | + | confirmButton.Set("className", "sv-button sv-button-danger") | |
| 473 | + | confirmButton.Set("textContent", "Confirm") | |
| 474 | + | actions.Call("appendChild", confirmButton) | |
| 475 | + | ||
| 476 | + | d.activeConfirmID++ | |
| 477 | + | confirmID := d.activeConfirmID | |
| 478 | + | ||
| 479 | + | var cleanup func() | |
| 480 | + | var cancelFn js.Func | |
| 481 | + | var confirmFn js.Func | |
| 482 | + | var keyFn js.Func | |
| 483 | + | var backdropFn js.Func | |
| 484 | + | cleaned := false | |
| 485 | + | confirmed := false | |
| 486 | + | cleanup = func() { | |
| 487 | + | if cleaned { | |
| 488 | + | return | |
| 489 | + | } | |
| 490 | + | cleaned = true | |
| 491 | + | if d.activeConfirmID == confirmID { | |
| 492 | + | d.activeConfirmCleanup = nil | |
| 493 | + | } | |
| 494 | + | if overlay.Truthy() { | |
| 495 | + | overlay.Call("remove") | |
| 496 | + | } | |
| 497 | + | if d.document.Truthy() && keyFn.Value.Truthy() { | |
| 498 | + | d.document.Call("removeEventListener", "keydown", keyFn) | |
| 499 | + | } | |
| 500 | + | cancelFn.Release() | |
| 501 | + | confirmFn.Release() | |
| 502 | + | keyFn.Release() | |
| 503 | + | backdropFn.Release() | |
| 504 | + | } | |
| 505 | + | runConfirm := func() { | |
| 506 | + | if confirmed { | |
| 507 | + | return | |
| 508 | + | } | |
| 509 | + | confirmed = true | |
| 510 | + | cleanup() | |
| 511 | + | onConfirm() | |
| 512 | + | } | |
| 513 | + | cancelFn = js.FuncOf(func(js.Value, []js.Value) any { | |
| 514 | + | cleanup() | |
| 515 | + | return nil | |
| 516 | + | }) | |
| 517 | + | confirmFn = js.FuncOf(func(js.Value, []js.Value) any { | |
| 518 | + | runConfirm() | |
| 519 | + | return nil | |
| 520 | + | }) | |
| 521 | + | keyFn = js.FuncOf(func(_ js.Value, args []js.Value) any { | |
| 522 | + | event := firstJSArg(args) | |
| 523 | + | if !event.Truthy() { | |
| 524 | + | return nil | |
| 525 | + | } | |
| 526 | + | switch strings.ToLower(event.Get("key").String()) { | |
| 527 | + | case "escape": | |
| 528 | + | event.Call("preventDefault") | |
| 529 | + | event.Call("stopPropagation") | |
| 530 | + | cleanup() | |
| 531 | + | case "enter": | |
| 532 | + | event.Call("preventDefault") | |
| 533 | + | event.Call("stopPropagation") | |
| 534 | + | runConfirm() | |
| 535 | + | } | |
| 536 | + | return nil | |
| 537 | + | }) | |
| 538 | + | backdropFn = js.FuncOf(func(_ js.Value, args []js.Value) any { | |
| 539 | + | event := firstJSArg(args) | |
| 540 | + | if event.Truthy() && event.Get("target").Equal(overlay) { | |
| 541 | + | cleanup() | |
| 542 | + | } | |
| 543 | + | return nil | |
| 544 | + | }) | |
| 545 | + | ||
| 546 | + | cancelButton.Call("addEventListener", "click", cancelFn) | |
| 547 | + | confirmButton.Call("addEventListener", "click", confirmFn) | |
| 548 | + | document := d.document | |
| 549 | + | document.Call("addEventListener", "keydown", keyFn) | |
| 550 | + | overlay.Call("addEventListener", "click", backdropFn) | |
| 551 | + | d.activeConfirmCleanup = cleanup | |
| 552 | + | body.Call("appendChild", overlay) | |
| 553 | + | confirmButton.Call("focus") | |
| 554 | + | return true | |
| 290 | 555 | } | |
| 291 | 556 | ||
| 292 | 557 | func (d *browserDOM) handleKeyboard(event js.Value) { | |
M internal/ui/systemviews/favorites.templ +10 -10
| @@ -63,7 +63,7 @@ templ FavoritesControls(data favoritesRenderData) { | |||
| 63 | 63 | <label><span>Title</span><input name="title" type="text" placeholder="Optional title"/></label> | |
| 64 | 64 | @FolderSelect("folder_id", data.Folders, nil, "Folder") | |
| 65 | 65 | @FavoriteCreateTagPicker(data.Tags) | |
| 66 | - | <button class="sv-button" type="submit">Add favorite</button> | |
| 66 | + | <button class="sv-button" type="submit">@ButtonIconLabel(iconPlus, "Add favorite")</button> | |
| 67 | 67 | </form> | |
| 68 | 68 | <div class="sv-filter-panel"> | |
| 69 | 69 | <div class="sv-button-row"> | |
| @@ -120,8 +120,8 @@ templ FavoriteItem(favorite *entity.Favorite, folders []*entity.Folder, tags []* | |||
| 120 | 120 | } | |
| 121 | 121 | </div> | |
| 122 | 122 | <div class="sv-history-row-actions"> | |
| 123 | - | <a class="sv-button sv-button-secondary" href={ templ.SafeURL(sanitizeHref(favorite.URL)) }>Open</a> | |
| 124 | - | <button type="button" class="sv-button sv-button-secondary sv-button-danger" data-sv-action="favorite.delete" data-id={ fmt.Sprintf("%d", favorite.ID) } data-sv-confirm="Delete this favorite?">Delete</button> | |
| 123 | + | <a class="sv-button sv-button-secondary" href={ templ.SafeURL(sanitizeHref(favorite.URL)) }>@ButtonIconLabel(iconExternal, "Open")</a> | |
| 124 | + | <button type="button" class="sv-button sv-button-secondary sv-button-danger sv-button-icon-only" data-sv-action="favorite.delete" data-id={ fmt.Sprintf("%d", favorite.ID) } data-sv-confirm="Delete this favorite?" aria-label="Delete this favorite">@IconOnly(iconTrash, "Delete this favorite")</button> | |
| 125 | 125 | </div> | |
| 126 | 126 | </div> | |
| 127 | 127 | @FavoriteEditForm(favorite, folders) | |
| @@ -137,7 +137,7 @@ templ FavoriteEditForm(favorite *entity.Favorite, folders []*entity.Folder) { | |||
| 137 | 137 | <label><span>Favicon</span><input name="favicon_url" type="url" value={ favorite.FaviconURL }/></label> | |
| 138 | 138 | @FolderSelect("folder_id", folders, favorite.FolderID, "Folder") | |
| 139 | 139 | @ShortcutSelect(favorite.ShortcutKey) | |
| 140 | - | <button class="sv-button sv-button-secondary" type="submit">Save</button> | |
| 140 | + | <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconSave, "Save")</button> | |
| 141 | 141 | </form> | |
| 142 | 142 | } | |
| 143 | 143 | ||
| @@ -160,7 +160,7 @@ templ FolderCreateForm() { | |||
| 160 | 160 | <form class="sv-inline-form" data-sv-action="folder.create"> | |
| 161 | 161 | <label><span>Name</span><input name="name" type="text" required/></label> | |
| 162 | 162 | <label><span>Icon</span><input name="icon" type="text" placeholder="Optional"/></label> | |
| 163 | - | <button class="sv-button sv-button-secondary" type="submit">Create folder</button> | |
| 163 | + | <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconFolder, "Create folder")</button> | |
| 164 | 164 | </form> | |
| 165 | 165 | } | |
| 166 | 166 | ||
| @@ -170,8 +170,8 @@ templ FolderItem(folder *entity.Folder) { | |||
| 170 | 170 | <input type="hidden" name="id" value={ fmt.Sprintf("%d", folder.ID) }/> | |
| 171 | 171 | <label><span>Name</span><input name="name" type="text" value={ folder.Name } required/></label> | |
| 172 | 172 | <label><span>Icon</span><input name="icon" type="text" value={ folder.Icon }/></label> | |
| 173 | - | <button class="sv-button sv-button-secondary" type="submit">Save</button> | |
| 174 | - | <button type="button" class="sv-button sv-button-secondary sv-button-danger" data-sv-action="folder.delete" data-id={ fmt.Sprintf("%d", folder.ID) } data-sv-confirm="Delete this folder? Favorites will move out of it.">Delete</button> | |
| 173 | + | <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconSave, "Save")</button> | |
| 174 | + | <button type="button" class="sv-button sv-button-secondary sv-button-danger sv-button-icon-only" data-sv-action="folder.delete" data-id={ fmt.Sprintf("%d", folder.ID) } data-sv-confirm="Delete this folder? Favorites will move out of it." aria-label="Delete this folder">@IconOnly(iconTrash, "Delete this folder")</button> | |
| 175 | 175 | </form> | |
| 176 | 176 | </li> | |
| 177 | 177 | } | |
| @@ -180,7 +180,7 @@ templ TagCreateForm() { | |||
| 180 | 180 | <form class="sv-inline-form" data-sv-action="tag.create"> | |
| 181 | 181 | <label><span>Name</span><input name="name" type="text" required/></label> | |
| 182 | 182 | <label><span>Color</span><input name="color" type="text" placeholder="#808080"/></label> | |
| 183 | - | <button class="sv-button sv-button-secondary" type="submit">Create tag</button> | |
| 183 | + | <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconTag, "Create tag")</button> | |
| 184 | 184 | </form> | |
| 185 | 185 | } | |
| 186 | 186 | ||
| @@ -191,8 +191,8 @@ templ TagItem(tag *entity.Tag) { | |||
| 191 | 191 | <span class="sv-tag-dot" style={ "background:" + safeTagColor(tag.Color) }></span> | |
| 192 | 192 | <label><span>Name</span><input name="name" type="text" value={ tag.Name } required/></label> | |
| 193 | 193 | <label><span>Color</span><input name="color" type="text" value={ tag.Color }/></label> | |
| 194 | - | <button class="sv-button sv-button-secondary" type="submit">Save</button> | |
| 195 | - | <button type="button" class="sv-button sv-button-secondary sv-button-danger" data-sv-action="tag.delete" data-id={ fmt.Sprintf("%d", tag.ID) } data-sv-confirm="Delete this tag?">Delete</button> | |
| 194 | + | <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconSave, "Save")</button> | |
| 195 | + | <button type="button" class="sv-button sv-button-secondary sv-button-danger sv-button-icon-only" data-sv-action="tag.delete" data-id={ fmt.Sprintf("%d", tag.ID) } data-sv-confirm="Delete this tag?" aria-label="Delete this tag">@IconOnly(iconTrash, "Delete this tag")</button> | |
| 196 | 196 | </form> | |
| 197 | 197 | </li> | |
| 198 | 198 | } | |
M internal/ui/systemviews/favorites_templ.go +163 -83
| @@ -263,7 +263,15 @@ func FavoritesControls(data favoritesRenderData) templ.Component { | |||
| 263 | 263 | if templ_7745c5c3_Err != nil { | |
| 264 | 264 | return templ_7745c5c3_Err | |
| 265 | 265 | } | |
| 266 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<button class=\"sv-button\" type=\"submit\">Add favorite</button></form><div class=\"sv-filter-panel\"><div class=\"sv-button-row\"><span class=\"sv-meta\">Folders</span> ") | |
| 266 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<button class=\"sv-button\" type=\"submit\">") | |
| 267 | + | if templ_7745c5c3_Err != nil { | |
| 268 | + | return templ_7745c5c3_Err | |
| 269 | + | } | |
| 270 | + | templ_7745c5c3_Err = ButtonIconLabel(iconPlus, "Add favorite").Render(ctx, templ_7745c5c3_Buffer) | |
| 271 | + | if templ_7745c5c3_Err != nil { | |
| 272 | + | return templ_7745c5c3_Err | |
| 273 | + | } | |
| 274 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</button></form><div class=\"sv-filter-panel\"><div class=\"sv-button-row\"><span class=\"sv-meta\">Folders</span> ") | |
| 267 | 275 | if templ_7745c5c3_Err != nil { | |
| 268 | 276 | return templ_7745c5c3_Err | |
| 269 | 277 | } | |
| @@ -272,7 +280,7 @@ func FavoritesControls(data favoritesRenderData) templ.Component { | |||
| 272 | 280 | if templ_7745c5c3_Err != nil { | |
| 273 | 281 | return templ_7745c5c3_Err | |
| 274 | 282 | } | |
| 275 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<button type=\"button\" class=\"") | |
| 283 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<button type=\"button\" class=\"") | |
| 276 | 284 | if templ_7745c5c3_Err != nil { | |
| 277 | 285 | return templ_7745c5c3_Err | |
| 278 | 286 | } | |
| @@ -285,7 +293,7 @@ func FavoritesControls(data favoritesRenderData) templ.Component { | |||
| 285 | 293 | if templ_7745c5c3_Err != nil { | |
| 286 | 294 | return templ_7745c5c3_Err | |
| 287 | 295 | } | |
| 288 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" aria-pressed=\"") | |
| 296 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" aria-pressed=\"") | |
| 289 | 297 | if templ_7745c5c3_Err != nil { | |
| 290 | 298 | return templ_7745c5c3_Err | |
| 291 | 299 | } | |
| @@ -298,7 +306,7 @@ func FavoritesControls(data favoritesRenderData) templ.Component { | |||
| 298 | 306 | if templ_7745c5c3_Err != nil { | |
| 299 | 307 | return templ_7745c5c3_Err | |
| 300 | 308 | } | |
| 301 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" data-sv-action=\"favorite.clearFilters\">All</button> ") | |
| 309 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" data-sv-action=\"favorite.clearFilters\">All</button> ") | |
| 302 | 310 | if templ_7745c5c3_Err != nil { | |
| 303 | 311 | return templ_7745c5c3_Err | |
| 304 | 312 | } | |
| @@ -307,7 +315,7 @@ func FavoritesControls(data favoritesRenderData) templ.Component { | |||
| 307 | 315 | if templ_7745c5c3_Err != nil { | |
| 308 | 316 | return templ_7745c5c3_Err | |
| 309 | 317 | } | |
| 310 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<button type=\"button\" class=\"") | |
| 318 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<button type=\"button\" class=\"") | |
| 311 | 319 | if templ_7745c5c3_Err != nil { | |
| 312 | 320 | return templ_7745c5c3_Err | |
| 313 | 321 | } | |
| @@ -320,7 +328,7 @@ func FavoritesControls(data favoritesRenderData) templ.Component { | |||
| 320 | 328 | if templ_7745c5c3_Err != nil { | |
| 321 | 329 | return templ_7745c5c3_Err | |
| 322 | 330 | } | |
| 323 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" aria-pressed=\"") | |
| 331 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" aria-pressed=\"") | |
| 324 | 332 | if templ_7745c5c3_Err != nil { | |
| 325 | 333 | return templ_7745c5c3_Err | |
| 326 | 334 | } | |
| @@ -333,7 +341,7 @@ func FavoritesControls(data favoritesRenderData) templ.Component { | |||
| 333 | 341 | if templ_7745c5c3_Err != nil { | |
| 334 | 342 | return templ_7745c5c3_Err | |
| 335 | 343 | } | |
| 336 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" data-sv-action=\"favorite.filterFolder\" data-folder-id=\"root\">Unfiled</button> ") | |
| 344 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" data-sv-action=\"favorite.filterFolder\" data-folder-id=\"root\">Unfiled</button> ") | |
| 337 | 345 | if templ_7745c5c3_Err != nil { | |
| 338 | 346 | return templ_7745c5c3_Err | |
| 339 | 347 | } | |
| @@ -344,7 +352,7 @@ func FavoritesControls(data favoritesRenderData) templ.Component { | |||
| 344 | 352 | if templ_7745c5c3_Err != nil { | |
| 345 | 353 | return templ_7745c5c3_Err | |
| 346 | 354 | } | |
| 347 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<button type=\"button\" class=\"") | |
| 355 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<button type=\"button\" class=\"") | |
| 348 | 356 | if templ_7745c5c3_Err != nil { | |
| 349 | 357 | return templ_7745c5c3_Err | |
| 350 | 358 | } | |
| @@ -357,7 +365,7 @@ func FavoritesControls(data favoritesRenderData) templ.Component { | |||
| 357 | 365 | if templ_7745c5c3_Err != nil { | |
| 358 | 366 | return templ_7745c5c3_Err | |
| 359 | 367 | } | |
| 360 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" aria-pressed=\"") | |
| 368 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" aria-pressed=\"") | |
| 361 | 369 | if templ_7745c5c3_Err != nil { | |
| 362 | 370 | return templ_7745c5c3_Err | |
| 363 | 371 | } | |
| @@ -370,7 +378,7 @@ func FavoritesControls(data favoritesRenderData) templ.Component { | |||
| 370 | 378 | if templ_7745c5c3_Err != nil { | |
| 371 | 379 | return templ_7745c5c3_Err | |
| 372 | 380 | } | |
| 373 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" data-sv-action=\"favorite.filterFolder\" data-folder-id=\"") | |
| 381 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" data-sv-action=\"favorite.filterFolder\" data-folder-id=\"") | |
| 374 | 382 | if templ_7745c5c3_Err != nil { | |
| 375 | 383 | return templ_7745c5c3_Err | |
| 376 | 384 | } | |
| @@ -383,7 +391,7 @@ func FavoritesControls(data favoritesRenderData) templ.Component { | |||
| 383 | 391 | if templ_7745c5c3_Err != nil { | |
| 384 | 392 | return templ_7745c5c3_Err | |
| 385 | 393 | } | |
| 386 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\">") | |
| 394 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\">") | |
| 387 | 395 | if templ_7745c5c3_Err != nil { | |
| 388 | 396 | return templ_7745c5c3_Err | |
| 389 | 397 | } | |
| @@ -396,13 +404,13 @@ func FavoritesControls(data favoritesRenderData) templ.Component { | |||
| 396 | 404 | if templ_7745c5c3_Err != nil { | |
| 397 | 405 | return templ_7745c5c3_Err | |
| 398 | 406 | } | |
| 399 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</button>") | |
| 407 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</button>") | |
| 400 | 408 | if templ_7745c5c3_Err != nil { | |
| 401 | 409 | return templ_7745c5c3_Err | |
| 402 | 410 | } | |
| 403 | 411 | } | |
| 404 | 412 | } | |
| 405 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</div><div class=\"sv-button-row\"><span class=\"sv-meta\">Tags</span> ") | |
| 413 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div><div class=\"sv-button-row\"><span class=\"sv-meta\">Tags</span> ") | |
| 406 | 414 | if templ_7745c5c3_Err != nil { | |
| 407 | 415 | return templ_7745c5c3_Err | |
| 408 | 416 | } | |
| @@ -413,7 +421,7 @@ func FavoritesControls(data favoritesRenderData) templ.Component { | |||
| 413 | 421 | if templ_7745c5c3_Err != nil { | |
| 414 | 422 | return templ_7745c5c3_Err | |
| 415 | 423 | } | |
| 416 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<button type=\"button\" class=\"") | |
| 424 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<button type=\"button\" class=\"") | |
| 417 | 425 | if templ_7745c5c3_Err != nil { | |
| 418 | 426 | return templ_7745c5c3_Err | |
| 419 | 427 | } | |
| @@ -426,7 +434,7 @@ func FavoritesControls(data favoritesRenderData) templ.Component { | |||
| 426 | 434 | if templ_7745c5c3_Err != nil { | |
| 427 | 435 | return templ_7745c5c3_Err | |
| 428 | 436 | } | |
| 429 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" aria-pressed=\"") | |
| 437 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" aria-pressed=\"") | |
| 430 | 438 | if templ_7745c5c3_Err != nil { | |
| 431 | 439 | return templ_7745c5c3_Err | |
| 432 | 440 | } | |
| @@ -439,7 +447,7 @@ func FavoritesControls(data favoritesRenderData) templ.Component { | |||
| 439 | 447 | if templ_7745c5c3_Err != nil { | |
| 440 | 448 | return templ_7745c5c3_Err | |
| 441 | 449 | } | |
| 442 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" data-sv-action=\"favorite.filterTag\" data-tag-id=\"") | |
| 450 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" data-sv-action=\"favorite.filterTag\" data-tag-id=\"") | |
| 443 | 451 | if templ_7745c5c3_Err != nil { | |
| 444 | 452 | return templ_7745c5c3_Err | |
| 445 | 453 | } | |
| @@ -452,7 +460,7 @@ func FavoritesControls(data favoritesRenderData) templ.Component { | |||
| 452 | 460 | if templ_7745c5c3_Err != nil { | |
| 453 | 461 | return templ_7745c5c3_Err | |
| 454 | 462 | } | |
| 455 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\"><span class=\"sv-tag-dot\" style=\"") | |
| 463 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\"><span class=\"sv-tag-dot\" style=\"") | |
| 456 | 464 | if templ_7745c5c3_Err != nil { | |
| 457 | 465 | return templ_7745c5c3_Err | |
| 458 | 466 | } | |
| @@ -465,7 +473,7 @@ func FavoritesControls(data favoritesRenderData) templ.Component { | |||
| 465 | 473 | if templ_7745c5c3_Err != nil { | |
| 466 | 474 | return templ_7745c5c3_Err | |
| 467 | 475 | } | |
| 468 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\"></span>") | |
| 476 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\"></span>") | |
| 469 | 477 | if templ_7745c5c3_Err != nil { | |
| 470 | 478 | return templ_7745c5c3_Err | |
| 471 | 479 | } | |
| @@ -478,13 +486,13 @@ func FavoritesControls(data favoritesRenderData) templ.Component { | |||
| 478 | 486 | if templ_7745c5c3_Err != nil { | |
| 479 | 487 | return templ_7745c5c3_Err | |
| 480 | 488 | } | |
| 481 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</button>") | |
| 489 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</button>") | |
| 482 | 490 | if templ_7745c5c3_Err != nil { | |
| 483 | 491 | return templ_7745c5c3_Err | |
| 484 | 492 | } | |
| 485 | 493 | } | |
| 486 | 494 | } | |
| 487 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</div></div><p class=\"sv-meta sv-key-hints\">Keys: <kbd>j</kbd>/<kbd>k</kbd> move, <kbd>Enter</kbd> open, <kbd>e</kbd> edit title, <kbd>d</kbd> delete focused favorite.</p>") | |
| 495 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</div></div><p class=\"sv-meta sv-key-hints\">Keys: <kbd>j</kbd>/<kbd>k</kbd> move, <kbd>Enter</kbd> open, <kbd>e</kbd> edit title, <kbd>d</kbd> delete focused favorite.</p>") | |
| 488 | 496 | if templ_7745c5c3_Err != nil { | |
| 489 | 497 | return templ_7745c5c3_Err | |
| 490 | 498 | } | |
| @@ -514,13 +522,13 @@ func FavoriteCreateTagPicker(tags []*entity.Tag) templ.Component { | |||
| 514 | 522 | } | |
| 515 | 523 | ctx = templ.ClearChildren(ctx) | |
| 516 | 524 | if len(tags) > 0 { | |
| 517 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<fieldset class=\"sv-tag-picker\"><legend>Tags</legend><div class=\"sv-tag-controls\">") | |
| 525 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<fieldset class=\"sv-tag-picker\"><legend>Tags</legend><div class=\"sv-tag-controls\">") | |
| 518 | 526 | if templ_7745c5c3_Err != nil { | |
| 519 | 527 | return templ_7745c5c3_Err | |
| 520 | 528 | } | |
| 521 | 529 | for _, tag := range tags { | |
| 522 | 530 | if tag != nil { | |
| 523 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<label class=\"sv-tag-toggle\"><input type=\"checkbox\" name=\"tags\" value=\"") | |
| 531 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<label class=\"sv-tag-toggle\"><input type=\"checkbox\" name=\"tags\" value=\"") | |
| 524 | 532 | if templ_7745c5c3_Err != nil { | |
| 525 | 533 | return templ_7745c5c3_Err | |
| 526 | 534 | } | |
| @@ -533,7 +541,7 @@ func FavoriteCreateTagPicker(tags []*entity.Tag) templ.Component { | |||
| 533 | 541 | if templ_7745c5c3_Err != nil { | |
| 534 | 542 | return templ_7745c5c3_Err | |
| 535 | 543 | } | |
| 536 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\"> <span><span class=\"sv-tag-dot\" style=\"") | |
| 544 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\"> <span><span class=\"sv-tag-dot\" style=\"") | |
| 537 | 545 | if templ_7745c5c3_Err != nil { | |
| 538 | 546 | return templ_7745c5c3_Err | |
| 539 | 547 | } | |
| @@ -546,7 +554,7 @@ func FavoriteCreateTagPicker(tags []*entity.Tag) templ.Component { | |||
| 546 | 554 | if templ_7745c5c3_Err != nil { | |
| 547 | 555 | return templ_7745c5c3_Err | |
| 548 | 556 | } | |
| 549 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\"></span>") | |
| 557 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\"></span>") | |
| 550 | 558 | if templ_7745c5c3_Err != nil { | |
| 551 | 559 | return templ_7745c5c3_Err | |
| 552 | 560 | } | |
| @@ -559,13 +567,13 @@ func FavoriteCreateTagPicker(tags []*entity.Tag) templ.Component { | |||
| 559 | 567 | if templ_7745c5c3_Err != nil { | |
| 560 | 568 | return templ_7745c5c3_Err | |
| 561 | 569 | } | |
| 562 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</span></label>") | |
| 570 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</span></label>") | |
| 563 | 571 | if templ_7745c5c3_Err != nil { | |
| 564 | 572 | return templ_7745c5c3_Err | |
| 565 | 573 | } | |
| 566 | 574 | } | |
| 567 | 575 | } | |
| 568 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</div></fieldset>") | |
| 576 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</div></fieldset>") | |
| 569 | 577 | if templ_7745c5c3_Err != nil { | |
| 570 | 578 | return templ_7745c5c3_Err | |
| 571 | 579 | } | |
| @@ -595,7 +603,7 @@ func FavoriteItem(favorite *entity.Favorite, folders []*entity.Folder, tags []*e | |||
| 595 | 603 | templ_7745c5c3_Var28 = templ.NopComponent | |
| 596 | 604 | } | |
| 597 | 605 | ctx = templ.ClearChildren(ctx) | |
| 598 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<li class=\"sv-list-row\"><article class=\"sv-favorite-item\" data-sv-favorite-row data-favorite-id=\"") | |
| 606 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<li class=\"sv-list-row\"><article class=\"sv-favorite-item\" data-sv-favorite-row data-favorite-id=\"") | |
| 599 | 607 | if templ_7745c5c3_Err != nil { | |
| 600 | 608 | return templ_7745c5c3_Err | |
| 601 | 609 | } | |
| @@ -608,7 +616,7 @@ func FavoriteItem(favorite *entity.Favorite, folders []*entity.Folder, tags []*e | |||
| 608 | 616 | if templ_7745c5c3_Err != nil { | |
| 609 | 617 | return templ_7745c5c3_Err | |
| 610 | 618 | } | |
| 611 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\"><div class=\"sv-favorite-header\"><div><a class=\"sv-link sv-favorite-open\" href=\"") | |
| 619 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\"><div class=\"sv-favorite-header\"><div><a class=\"sv-link sv-favorite-open\" href=\"") | |
| 612 | 620 | if templ_7745c5c3_Err != nil { | |
| 613 | 621 | return templ_7745c5c3_Err | |
| 614 | 622 | } | |
| @@ -621,7 +629,7 @@ func FavoriteItem(favorite *entity.Favorite, folders []*entity.Folder, tags []*e | |||
| 621 | 629 | if templ_7745c5c3_Err != nil { | |
| 622 | 630 | return templ_7745c5c3_Err | |
| 623 | 631 | } | |
| 624 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\">") | |
| 632 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\">") | |
| 625 | 633 | if templ_7745c5c3_Err != nil { | |
| 626 | 634 | return templ_7745c5c3_Err | |
| 627 | 635 | } | |
| @@ -634,7 +642,7 @@ func FavoriteItem(favorite *entity.Favorite, folders []*entity.Folder, tags []*e | |||
| 634 | 642 | if templ_7745c5c3_Err != nil { | |
| 635 | 643 | return templ_7745c5c3_Err | |
| 636 | 644 | } | |
| 637 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</a><p class=\"sv-history-url\">") | |
| 645 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</a><p class=\"sv-history-url\">") | |
| 638 | 646 | if templ_7745c5c3_Err != nil { | |
| 639 | 647 | return templ_7745c5c3_Err | |
| 640 | 648 | } | |
| @@ -647,12 +655,12 @@ func FavoriteItem(favorite *entity.Favorite, folders []*entity.Folder, tags []*e | |||
| 647 | 655 | if templ_7745c5c3_Err != nil { | |
| 648 | 656 | return templ_7745c5c3_Err | |
| 649 | 657 | } | |
| 650 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</p>") | |
| 658 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</p>") | |
| 651 | 659 | if templ_7745c5c3_Err != nil { | |
| 652 | 660 | return templ_7745c5c3_Err | |
| 653 | 661 | } | |
| 654 | 662 | if favoriteMetaText(favorite, folders) != "" { | |
| 655 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<p class=\"sv-meta\">") | |
| 663 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<p class=\"sv-meta\">") | |
| 656 | 664 | if templ_7745c5c3_Err != nil { | |
| 657 | 665 | return templ_7745c5c3_Err | |
| 658 | 666 | } | |
| @@ -665,12 +673,12 @@ func FavoriteItem(favorite *entity.Favorite, folders []*entity.Folder, tags []*e | |||
| 665 | 673 | if templ_7745c5c3_Err != nil { | |
| 666 | 674 | return templ_7745c5c3_Err | |
| 667 | 675 | } | |
| 668 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</p>") | |
| 676 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</p>") | |
| 669 | 677 | if templ_7745c5c3_Err != nil { | |
| 670 | 678 | return templ_7745c5c3_Err | |
| 671 | 679 | } | |
| 672 | 680 | } | |
| 673 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</div><div class=\"sv-history-row-actions\"><a class=\"sv-button sv-button-secondary\" href=\"") | |
| 681 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</div><div class=\"sv-history-row-actions\"><a class=\"sv-button sv-button-secondary\" href=\"") | |
| 674 | 682 | if templ_7745c5c3_Err != nil { | |
| 675 | 683 | return templ_7745c5c3_Err | |
| 676 | 684 | } | |
| @@ -683,20 +691,36 @@ func FavoriteItem(favorite *entity.Favorite, folders []*entity.Folder, tags []*e | |||
| 683 | 691 | if templ_7745c5c3_Err != nil { | |
| 684 | 692 | return templ_7745c5c3_Err | |
| 685 | 693 | } | |
| 686 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\">Open</a> <button type=\"button\" class=\"sv-button sv-button-secondary sv-button-danger\" data-sv-action=\"favorite.delete\" data-id=\"") | |
| 694 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\">") | |
| 695 | + | if templ_7745c5c3_Err != nil { | |
| 696 | + | return templ_7745c5c3_Err | |
| 697 | + | } | |
| 698 | + | templ_7745c5c3_Err = ButtonIconLabel(iconExternal, "Open").Render(ctx, templ_7745c5c3_Buffer) | |
| 699 | + | if templ_7745c5c3_Err != nil { | |
| 700 | + | return templ_7745c5c3_Err | |
| 701 | + | } | |
| 702 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</a> <button type=\"button\" class=\"sv-button sv-button-secondary sv-button-danger sv-button-icon-only\" data-sv-action=\"favorite.delete\" data-id=\"") | |
| 687 | 703 | if templ_7745c5c3_Err != nil { | |
| 688 | 704 | return templ_7745c5c3_Err | |
| 689 | 705 | } | |
| 690 | 706 | var templ_7745c5c3_Var35 string | |
| 691 | 707 | templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", favorite.ID)) | |
| 692 | 708 | if templ_7745c5c3_Err != nil { | |
| 693 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 124, Col: 155} | |
| 709 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 124, Col: 175} | |
| 694 | 710 | } | |
| 695 | 711 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) | |
| 696 | 712 | if templ_7745c5c3_Err != nil { | |
| 697 | 713 | return templ_7745c5c3_Err | |
| 698 | 714 | } | |
| 699 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\" data-sv-confirm=\"Delete this favorite?\">Delete</button></div></div>") | |
| 715 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\" data-sv-confirm=\"Delete this favorite?\" aria-label=\"Delete this favorite\">") | |
| 716 | + | if templ_7745c5c3_Err != nil { | |
| 717 | + | return templ_7745c5c3_Err | |
| 718 | + | } | |
| 719 | + | templ_7745c5c3_Err = IconOnly(iconTrash, "Delete this favorite").Render(ctx, templ_7745c5c3_Buffer) | |
| 720 | + | if templ_7745c5c3_Err != nil { | |
| 721 | + | return templ_7745c5c3_Err | |
| 722 | + | } | |
| 723 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</button></div></div>") | |
| 700 | 724 | if templ_7745c5c3_Err != nil { | |
| 701 | 725 | return templ_7745c5c3_Err | |
| 702 | 726 | } | |
| @@ -708,7 +732,7 @@ func FavoriteItem(favorite *entity.Favorite, folders []*entity.Folder, tags []*e | |||
| 708 | 732 | if templ_7745c5c3_Err != nil { | |
| 709 | 733 | return templ_7745c5c3_Err | |
| 710 | 734 | } | |
| 711 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</article></li>") | |
| 735 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</article></li>") | |
| 712 | 736 | if templ_7745c5c3_Err != nil { | |
| 713 | 737 | return templ_7745c5c3_Err | |
| 714 | 738 | } | |
| @@ -737,7 +761,7 @@ func FavoriteEditForm(favorite *entity.Favorite, folders []*entity.Folder) templ | |||
| 737 | 761 | templ_7745c5c3_Var36 = templ.NopComponent | |
| 738 | 762 | } | |
| 739 | 763 | ctx = templ.ClearChildren(ctx) | |
| 740 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<form class=\"sv-inline-form\" data-sv-action=\"favorite.update\"><input type=\"hidden\" name=\"id\" value=\"") | |
| 764 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<form class=\"sv-inline-form\" data-sv-action=\"favorite.update\"><input type=\"hidden\" name=\"id\" value=\"") | |
| 741 | 765 | if templ_7745c5c3_Err != nil { | |
| 742 | 766 | return templ_7745c5c3_Err | |
| 743 | 767 | } | |
| @@ -750,7 +774,7 @@ func FavoriteEditForm(favorite *entity.Favorite, folders []*entity.Folder) templ | |||
| 750 | 774 | if templ_7745c5c3_Err != nil { | |
| 751 | 775 | return templ_7745c5c3_Err | |
| 752 | 776 | } | |
| 753 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\"> <label><span>Title</span><input name=\"title\" type=\"text\" value=\"") | |
| 777 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\"> <label><span>Title</span><input name=\"title\" type=\"text\" value=\"") | |
| 754 | 778 | if templ_7745c5c3_Err != nil { | |
| 755 | 779 | return templ_7745c5c3_Err | |
| 756 | 780 | } | |
| @@ -763,7 +787,7 @@ func FavoriteEditForm(favorite *entity.Favorite, folders []*entity.Folder) templ | |||
| 763 | 787 | if templ_7745c5c3_Err != nil { | |
| 764 | 788 | return templ_7745c5c3_Err | |
| 765 | 789 | } | |
| 766 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\"></label> <label><span>Favicon</span><input name=\"favicon_url\" type=\"url\" value=\"") | |
| 790 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\"></label> <label><span>Favicon</span><input name=\"favicon_url\" type=\"url\" value=\"") | |
| 767 | 791 | if templ_7745c5c3_Err != nil { | |
| 768 | 792 | return templ_7745c5c3_Err | |
| 769 | 793 | } | |
| @@ -776,7 +800,7 @@ func FavoriteEditForm(favorite *entity.Favorite, folders []*entity.Folder) templ | |||
| 776 | 800 | if templ_7745c5c3_Err != nil { | |
| 777 | 801 | return templ_7745c5c3_Err | |
| 778 | 802 | } | |
| 779 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\"></label>") | |
| 803 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\"></label>") | |
| 780 | 804 | if templ_7745c5c3_Err != nil { | |
| 781 | 805 | return templ_7745c5c3_Err | |
| 782 | 806 | } | |
| @@ -788,7 +812,15 @@ func FavoriteEditForm(favorite *entity.Favorite, folders []*entity.Folder) templ | |||
| 788 | 812 | if templ_7745c5c3_Err != nil { | |
| 789 | 813 | return templ_7745c5c3_Err | |
| 790 | 814 | } | |
| 791 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<button class=\"sv-button sv-button-secondary\" type=\"submit\">Save</button></form>") | |
| 815 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<button class=\"sv-button sv-button-secondary\" type=\"submit\">") | |
| 816 | + | if templ_7745c5c3_Err != nil { | |
| 817 | + | return templ_7745c5c3_Err | |
| 818 | + | } | |
| 819 | + | templ_7745c5c3_Err = ButtonIconLabel(iconSave, "Save").Render(ctx, templ_7745c5c3_Buffer) | |
| 820 | + | if templ_7745c5c3_Err != nil { | |
| 821 | + | return templ_7745c5c3_Err | |
| 822 | + | } | |
| 823 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</button></form>") | |
| 792 | 824 | if templ_7745c5c3_Err != nil { | |
| 793 | 825 | return templ_7745c5c3_Err | |
| 794 | 826 | } | |
| @@ -818,7 +850,7 @@ func FavoriteTagControls(favorite *entity.Favorite, tags []*entity.Tag) templ.Co | |||
| 818 | 850 | } | |
| 819 | 851 | ctx = templ.ClearChildren(ctx) | |
| 820 | 852 | if len(tags) > 0 { | |
| 821 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<div class=\"sv-tag-controls\"><span class=\"sv-meta\">Tags</span> ") | |
| 853 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<div class=\"sv-tag-controls\"><span class=\"sv-meta\">Tags</span> ") | |
| 822 | 854 | if templ_7745c5c3_Err != nil { | |
| 823 | 855 | return templ_7745c5c3_Err | |
| 824 | 856 | } | |
| @@ -829,7 +861,7 @@ func FavoriteTagControls(favorite *entity.Favorite, tags []*entity.Tag) templ.Co | |||
| 829 | 861 | if templ_7745c5c3_Err != nil { | |
| 830 | 862 | return templ_7745c5c3_Err | |
| 831 | 863 | } | |
| 832 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<button type=\"button\" class=\"") | |
| 864 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<button type=\"button\" class=\"") | |
| 833 | 865 | if templ_7745c5c3_Err != nil { | |
| 834 | 866 | return templ_7745c5c3_Err | |
| 835 | 867 | } | |
| @@ -842,7 +874,7 @@ func FavoriteTagControls(favorite *entity.Favorite, tags []*entity.Tag) templ.Co | |||
| 842 | 874 | if templ_7745c5c3_Err != nil { | |
| 843 | 875 | return templ_7745c5c3_Err | |
| 844 | 876 | } | |
| 845 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\" data-sv-action=\"") | |
| 877 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\" data-sv-action=\"") | |
| 846 | 878 | if templ_7745c5c3_Err != nil { | |
| 847 | 879 | return templ_7745c5c3_Err | |
| 848 | 880 | } | |
| @@ -855,7 +887,7 @@ func FavoriteTagControls(favorite *entity.Favorite, tags []*entity.Tag) templ.Co | |||
| 855 | 887 | if templ_7745c5c3_Err != nil { | |
| 856 | 888 | return templ_7745c5c3_Err | |
| 857 | 889 | } | |
| 858 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\" data-favorite-id=\"") | |
| 890 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\" data-favorite-id=\"") | |
| 859 | 891 | if templ_7745c5c3_Err != nil { | |
| 860 | 892 | return templ_7745c5c3_Err | |
| 861 | 893 | } | |
| @@ -868,7 +900,7 @@ func FavoriteTagControls(favorite *entity.Favorite, tags []*entity.Tag) templ.Co | |||
| 868 | 900 | if templ_7745c5c3_Err != nil { | |
| 869 | 901 | return templ_7745c5c3_Err | |
| 870 | 902 | } | |
| 871 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\" data-tag-id=\"") | |
| 903 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\" data-tag-id=\"") | |
| 872 | 904 | if templ_7745c5c3_Err != nil { | |
| 873 | 905 | return templ_7745c5c3_Err | |
| 874 | 906 | } | |
| @@ -881,7 +913,7 @@ func FavoriteTagControls(favorite *entity.Favorite, tags []*entity.Tag) templ.Co | |||
| 881 | 913 | if templ_7745c5c3_Err != nil { | |
| 882 | 914 | return templ_7745c5c3_Err | |
| 883 | 915 | } | |
| 884 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\"><span class=\"sv-tag-dot\" style=\"") | |
| 916 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\"><span class=\"sv-tag-dot\" style=\"") | |
| 885 | 917 | if templ_7745c5c3_Err != nil { | |
| 886 | 918 | return templ_7745c5c3_Err | |
| 887 | 919 | } | |
| @@ -894,7 +926,7 @@ func FavoriteTagControls(favorite *entity.Favorite, tags []*entity.Tag) templ.Co | |||
| 894 | 926 | if templ_7745c5c3_Err != nil { | |
| 895 | 927 | return templ_7745c5c3_Err | |
| 896 | 928 | } | |
| 897 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\"></span>") | |
| 929 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\"></span>") | |
| 898 | 930 | if templ_7745c5c3_Err != nil { | |
| 899 | 931 | return templ_7745c5c3_Err | |
| 900 | 932 | } | |
| @@ -907,13 +939,13 @@ func FavoriteTagControls(favorite *entity.Favorite, tags []*entity.Tag) templ.Co | |||
| 907 | 939 | if templ_7745c5c3_Err != nil { | |
| 908 | 940 | return templ_7745c5c3_Err | |
| 909 | 941 | } | |
| 910 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</button>") | |
| 942 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</button>") | |
| 911 | 943 | if templ_7745c5c3_Err != nil { | |
| 912 | 944 | return templ_7745c5c3_Err | |
| 913 | 945 | } | |
| 914 | 946 | } | |
| 915 | 947 | } | |
| 916 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</div>") | |
| 948 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</div>") | |
| 917 | 949 | if templ_7745c5c3_Err != nil { | |
| 918 | 950 | return templ_7745c5c3_Err | |
| 919 | 951 | } | |
| @@ -943,7 +975,15 @@ func FolderCreateForm() templ.Component { | |||
| 943 | 975 | templ_7745c5c3_Var48 = templ.NopComponent | |
| 944 | 976 | } | |
| 945 | 977 | ctx = templ.ClearChildren(ctx) | |
| 946 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<form class=\"sv-inline-form\" data-sv-action=\"folder.create\"><label><span>Name</span><input name=\"name\" type=\"text\" required></label> <label><span>Icon</span><input name=\"icon\" type=\"text\" placeholder=\"Optional\"></label> <button class=\"sv-button sv-button-secondary\" type=\"submit\">Create folder</button></form>") | |
| 978 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<form class=\"sv-inline-form\" data-sv-action=\"folder.create\"><label><span>Name</span><input name=\"name\" type=\"text\" required></label> <label><span>Icon</span><input name=\"icon\" type=\"text\" placeholder=\"Optional\"></label> <button class=\"sv-button sv-button-secondary\" type=\"submit\">") | |
| 979 | + | if templ_7745c5c3_Err != nil { | |
| 980 | + | return templ_7745c5c3_Err | |
| 981 | + | } | |
| 982 | + | templ_7745c5c3_Err = ButtonIconLabel(iconFolder, "Create folder").Render(ctx, templ_7745c5c3_Buffer) | |
| 983 | + | if templ_7745c5c3_Err != nil { | |
| 984 | + | return templ_7745c5c3_Err | |
| 985 | + | } | |
| 986 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</button></form>") | |
| 947 | 987 | if templ_7745c5c3_Err != nil { | |
| 948 | 988 | return templ_7745c5c3_Err | |
| 949 | 989 | } | |
| @@ -972,7 +1012,7 @@ func FolderItem(folder *entity.Folder) templ.Component { | |||
| 972 | 1012 | templ_7745c5c3_Var49 = templ.NopComponent | |
| 973 | 1013 | } | |
| 974 | 1014 | ctx = templ.ClearChildren(ctx) | |
| 975 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<li class=\"sv-list-row\"><form class=\"sv-inline-form\" data-sv-action=\"folder.update\"><input type=\"hidden\" name=\"id\" value=\"") | |
| 1015 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<li class=\"sv-list-row\"><form class=\"sv-inline-form\" data-sv-action=\"folder.update\"><input type=\"hidden\" name=\"id\" value=\"") | |
| 976 | 1016 | if templ_7745c5c3_Err != nil { | |
| 977 | 1017 | return templ_7745c5c3_Err | |
| 978 | 1018 | } | |
| @@ -985,7 +1025,7 @@ func FolderItem(folder *entity.Folder) templ.Component { | |||
| 985 | 1025 | if templ_7745c5c3_Err != nil { | |
| 986 | 1026 | return templ_7745c5c3_Err | |
| 987 | 1027 | } | |
| 988 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\"> <label><span>Name</span><input name=\"name\" type=\"text\" value=\"") | |
| 1028 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\"> <label><span>Name</span><input name=\"name\" type=\"text\" value=\"") | |
| 989 | 1029 | if templ_7745c5c3_Err != nil { | |
| 990 | 1030 | return templ_7745c5c3_Err | |
| 991 | 1031 | } | |
| @@ -998,7 +1038,7 @@ func FolderItem(folder *entity.Folder) templ.Component { | |||
| 998 | 1038 | if templ_7745c5c3_Err != nil { | |
| 999 | 1039 | return templ_7745c5c3_Err | |
| 1000 | 1040 | } | |
| 1001 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\" required></label> <label><span>Icon</span><input name=\"icon\" type=\"text\" value=\"") | |
| 1041 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\" required></label> <label><span>Icon</span><input name=\"icon\" type=\"text\" value=\"") | |
| 1002 | 1042 | if templ_7745c5c3_Err != nil { | |
| 1003 | 1043 | return templ_7745c5c3_Err | |
| 1004 | 1044 | } | |
| @@ -1011,20 +1051,36 @@ func FolderItem(folder *entity.Folder) templ.Component { | |||
| 1011 | 1051 | if templ_7745c5c3_Err != nil { | |
| 1012 | 1052 | return templ_7745c5c3_Err | |
| 1013 | 1053 | } | |
| 1014 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "\"></label> <button class=\"sv-button sv-button-secondary\" type=\"submit\">Save</button> <button type=\"button\" class=\"sv-button sv-button-secondary sv-button-danger\" data-sv-action=\"folder.delete\" data-id=\"") | |
| 1054 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "\"></label> <button class=\"sv-button sv-button-secondary\" type=\"submit\">") | |
| 1055 | + | if templ_7745c5c3_Err != nil { | |
| 1056 | + | return templ_7745c5c3_Err | |
| 1057 | + | } | |
| 1058 | + | templ_7745c5c3_Err = ButtonIconLabel(iconSave, "Save").Render(ctx, templ_7745c5c3_Buffer) | |
| 1059 | + | if templ_7745c5c3_Err != nil { | |
| 1060 | + | return templ_7745c5c3_Err | |
| 1061 | + | } | |
| 1062 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</button> <button type=\"button\" class=\"sv-button sv-button-secondary sv-button-danger sv-button-icon-only\" data-sv-action=\"folder.delete\" data-id=\"") | |
| 1015 | 1063 | if templ_7745c5c3_Err != nil { | |
| 1016 | 1064 | return templ_7745c5c3_Err | |
| 1017 | 1065 | } | |
| 1018 | 1066 | var templ_7745c5c3_Var53 string | |
| 1019 | 1067 | templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", folder.ID)) | |
| 1020 | 1068 | if templ_7745c5c3_Err != nil { | |
| 1021 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 174, Col: 149} | |
| 1069 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 174, Col: 169} | |
| 1022 | 1070 | } | |
| 1023 | 1071 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53)) | |
| 1024 | 1072 | if templ_7745c5c3_Err != nil { | |
| 1025 | 1073 | return templ_7745c5c3_Err | |
| 1026 | 1074 | } | |
| 1027 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "\" data-sv-confirm=\"Delete this folder? Favorites will move out of it.\">Delete</button></form></li>") | |
| 1075 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\" data-sv-confirm=\"Delete this folder? Favorites will move out of it.\" aria-label=\"Delete this folder\">") | |
| 1076 | + | if templ_7745c5c3_Err != nil { | |
| 1077 | + | return templ_7745c5c3_Err | |
| 1078 | + | } | |
| 1079 | + | templ_7745c5c3_Err = IconOnly(iconTrash, "Delete this folder").Render(ctx, templ_7745c5c3_Buffer) | |
| 1080 | + | if templ_7745c5c3_Err != nil { | |
| 1081 | + | return templ_7745c5c3_Err | |
| 1082 | + | } | |
| 1083 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</button></form></li>") | |
| 1028 | 1084 | if templ_7745c5c3_Err != nil { | |
| 1029 | 1085 | return templ_7745c5c3_Err | |
| 1030 | 1086 | } | |
| @@ -1053,7 +1109,15 @@ func TagCreateForm() templ.Component { | |||
| 1053 | 1109 | templ_7745c5c3_Var54 = templ.NopComponent | |
| 1054 | 1110 | } | |
| 1055 | 1111 | ctx = templ.ClearChildren(ctx) | |
| 1056 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<form class=\"sv-inline-form\" data-sv-action=\"tag.create\"><label><span>Name</span><input name=\"name\" type=\"text\" required></label> <label><span>Color</span><input name=\"color\" type=\"text\" placeholder=\"#808080\"></label> <button class=\"sv-button sv-button-secondary\" type=\"submit\">Create tag</button></form>") | |
| 1112 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<form class=\"sv-inline-form\" data-sv-action=\"tag.create\"><label><span>Name</span><input name=\"name\" type=\"text\" required></label> <label><span>Color</span><input name=\"color\" type=\"text\" placeholder=\"#808080\"></label> <button class=\"sv-button sv-button-secondary\" type=\"submit\">") | |
| 1113 | + | if templ_7745c5c3_Err != nil { | |
| 1114 | + | return templ_7745c5c3_Err | |
| 1115 | + | } | |
| 1116 | + | templ_7745c5c3_Err = ButtonIconLabel(iconTag, "Create tag").Render(ctx, templ_7745c5c3_Buffer) | |
| 1117 | + | if templ_7745c5c3_Err != nil { | |
| 1118 | + | return templ_7745c5c3_Err | |
| 1119 | + | } | |
| 1120 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</button></form>") | |
| 1057 | 1121 | if templ_7745c5c3_Err != nil { | |
| 1058 | 1122 | return templ_7745c5c3_Err | |
| 1059 | 1123 | } | |
| @@ -1082,7 +1146,7 @@ func TagItem(tag *entity.Tag) templ.Component { | |||
| 1082 | 1146 | templ_7745c5c3_Var55 = templ.NopComponent | |
| 1083 | 1147 | } | |
| 1084 | 1148 | ctx = templ.ClearChildren(ctx) | |
| 1085 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<li class=\"sv-list-row\"><form class=\"sv-inline-form\" data-sv-action=\"tag.update\"><input type=\"hidden\" name=\"id\" value=\"") | |
| 1149 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<li class=\"sv-list-row\"><form class=\"sv-inline-form\" data-sv-action=\"tag.update\"><input type=\"hidden\" name=\"id\" value=\"") | |
| 1086 | 1150 | if templ_7745c5c3_Err != nil { | |
| 1087 | 1151 | return templ_7745c5c3_Err | |
| 1088 | 1152 | } | |
| @@ -1095,7 +1159,7 @@ func TagItem(tag *entity.Tag) templ.Component { | |||
| 1095 | 1159 | if templ_7745c5c3_Err != nil { | |
| 1096 | 1160 | return templ_7745c5c3_Err | |
| 1097 | 1161 | } | |
| 1098 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\"> <span class=\"sv-tag-dot\" style=\"") | |
| 1162 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "\"> <span class=\"sv-tag-dot\" style=\"") | |
| 1099 | 1163 | if templ_7745c5c3_Err != nil { | |
| 1100 | 1164 | return templ_7745c5c3_Err | |
| 1101 | 1165 | } | |
| @@ -1108,7 +1172,7 @@ func TagItem(tag *entity.Tag) templ.Component { | |||
| 1108 | 1172 | if templ_7745c5c3_Err != nil { | |
| 1109 | 1173 | return templ_7745c5c3_Err | |
| 1110 | 1174 | } | |
| 1111 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "\"></span> <label><span>Name</span><input name=\"name\" type=\"text\" value=\"") | |
| 1175 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "\"></span> <label><span>Name</span><input name=\"name\" type=\"text\" value=\"") | |
| 1112 | 1176 | if templ_7745c5c3_Err != nil { | |
| 1113 | 1177 | return templ_7745c5c3_Err | |
| 1114 | 1178 | } | |
| @@ -1121,7 +1185,7 @@ func TagItem(tag *entity.Tag) templ.Component { | |||
| 1121 | 1185 | if templ_7745c5c3_Err != nil { | |
| 1122 | 1186 | return templ_7745c5c3_Err | |
| 1123 | 1187 | } | |
| 1124 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\" required></label> <label><span>Color</span><input name=\"color\" type=\"text\" value=\"") | |
| 1188 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "\" required></label> <label><span>Color</span><input name=\"color\" type=\"text\" value=\"") | |
| 1125 | 1189 | if templ_7745c5c3_Err != nil { | |
| 1126 | 1190 | return templ_7745c5c3_Err | |
| 1127 | 1191 | } | |
| @@ -1134,20 +1198,36 @@ func TagItem(tag *entity.Tag) templ.Component { | |||
| 1134 | 1198 | if templ_7745c5c3_Err != nil { | |
| 1135 | 1199 | return templ_7745c5c3_Err | |
| 1136 | 1200 | } | |
| 1137 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\"></label> <button class=\"sv-button sv-button-secondary\" type=\"submit\">Save</button> <button type=\"button\" class=\"sv-button sv-button-secondary sv-button-danger\" data-sv-action=\"tag.delete\" data-id=\"") | |
| 1201 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "\"></label> <button class=\"sv-button sv-button-secondary\" type=\"submit\">") | |
| 1202 | + | if templ_7745c5c3_Err != nil { | |
| 1203 | + | return templ_7745c5c3_Err | |
| 1204 | + | } | |
| 1205 | + | templ_7745c5c3_Err = ButtonIconLabel(iconSave, "Save").Render(ctx, templ_7745c5c3_Buffer) | |
| 1206 | + | if templ_7745c5c3_Err != nil { | |
| 1207 | + | return templ_7745c5c3_Err | |
| 1208 | + | } | |
| 1209 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</button> <button type=\"button\" class=\"sv-button sv-button-secondary sv-button-danger sv-button-icon-only\" data-sv-action=\"tag.delete\" data-id=\"") | |
| 1138 | 1210 | if templ_7745c5c3_Err != nil { | |
| 1139 | 1211 | return templ_7745c5c3_Err | |
| 1140 | 1212 | } | |
| 1141 | 1213 | var templ_7745c5c3_Var60 string | |
| 1142 | 1214 | templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", tag.ID)) | |
| 1143 | 1215 | if templ_7745c5c3_Err != nil { | |
| 1144 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 195, Col: 143} | |
| 1216 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 195, Col: 163} | |
| 1145 | 1217 | } | |
| 1146 | 1218 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60)) | |
| 1147 | 1219 | if templ_7745c5c3_Err != nil { | |
| 1148 | 1220 | return templ_7745c5c3_Err | |
| 1149 | 1221 | } | |
| 1150 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "\" data-sv-confirm=\"Delete this tag?\">Delete</button></form></li>") | |
| 1222 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "\" data-sv-confirm=\"Delete this tag?\" aria-label=\"Delete this tag\">") | |
| 1223 | + | if templ_7745c5c3_Err != nil { | |
| 1224 | + | return templ_7745c5c3_Err | |
| 1225 | + | } | |
| 1226 | + | templ_7745c5c3_Err = IconOnly(iconTrash, "Delete this tag").Render(ctx, templ_7745c5c3_Buffer) | |
| 1227 | + | if templ_7745c5c3_Err != nil { | |
| 1228 | + | return templ_7745c5c3_Err | |
| 1229 | + | } | |
| 1230 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</button></form></li>") | |
| 1151 | 1231 | if templ_7745c5c3_Err != nil { | |
| 1152 | 1232 | return templ_7745c5c3_Err | |
| 1153 | 1233 | } | |
| @@ -1176,7 +1256,7 @@ func FolderSelect(name string, folders []*entity.Folder, selected *entity.Folder | |||
| 1176 | 1256 | templ_7745c5c3_Var61 = templ.NopComponent | |
| 1177 | 1257 | } | |
| 1178 | 1258 | ctx = templ.ClearChildren(ctx) | |
| 1179 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<label><span>") | |
| 1259 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "<label><span>") | |
| 1180 | 1260 | if templ_7745c5c3_Err != nil { | |
| 1181 | 1261 | return templ_7745c5c3_Err | |
| 1182 | 1262 | } | |
| @@ -1189,7 +1269,7 @@ func FolderSelect(name string, folders []*entity.Folder, selected *entity.Folder | |||
| 1189 | 1269 | if templ_7745c5c3_Err != nil { | |
| 1190 | 1270 | return templ_7745c5c3_Err | |
| 1191 | 1271 | } | |
| 1192 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</span><select name=\"") | |
| 1272 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</span><select name=\"") | |
| 1193 | 1273 | if templ_7745c5c3_Err != nil { | |
| 1194 | 1274 | return templ_7745c5c3_Err | |
| 1195 | 1275 | } | |
| @@ -1202,13 +1282,13 @@ func FolderSelect(name string, folders []*entity.Folder, selected *entity.Folder | |||
| 1202 | 1282 | if templ_7745c5c3_Err != nil { | |
| 1203 | 1283 | return templ_7745c5c3_Err | |
| 1204 | 1284 | } | |
| 1205 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "\"><option value=\"\">Unfiled</option> ") | |
| 1285 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\"><option value=\"\">Unfiled</option> ") | |
| 1206 | 1286 | if templ_7745c5c3_Err != nil { | |
| 1207 | 1287 | return templ_7745c5c3_Err | |
| 1208 | 1288 | } | |
| 1209 | 1289 | for _, folder := range folders { | |
| 1210 | 1290 | if folder != nil { | |
| 1211 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<option value=\"") | |
| 1291 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<option value=\"") | |
| 1212 | 1292 | if templ_7745c5c3_Err != nil { | |
| 1213 | 1293 | return templ_7745c5c3_Err | |
| 1214 | 1294 | } | |
| @@ -1221,17 +1301,17 @@ func FolderSelect(name string, folders []*entity.Folder, selected *entity.Folder | |||
| 1221 | 1301 | if templ_7745c5c3_Err != nil { | |
| 1222 | 1302 | return templ_7745c5c3_Err | |
| 1223 | 1303 | } | |
| 1224 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "\"") | |
| 1304 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "\"") | |
| 1225 | 1305 | if templ_7745c5c3_Err != nil { | |
| 1226 | 1306 | return templ_7745c5c3_Err | |
| 1227 | 1307 | } | |
| 1228 | 1308 | if folderSelected(selected, folder.ID) { | |
| 1229 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, " selected") | |
| 1309 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, " selected") | |
| 1230 | 1310 | if templ_7745c5c3_Err != nil { | |
| 1231 | 1311 | return templ_7745c5c3_Err | |
| 1232 | 1312 | } | |
| 1233 | 1313 | } | |
| 1234 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, ">") | |
| 1314 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, ">") | |
| 1235 | 1315 | if templ_7745c5c3_Err != nil { | |
| 1236 | 1316 | return templ_7745c5c3_Err | |
| 1237 | 1317 | } | |
| @@ -1244,13 +1324,13 @@ func FolderSelect(name string, folders []*entity.Folder, selected *entity.Folder | |||
| 1244 | 1324 | if templ_7745c5c3_Err != nil { | |
| 1245 | 1325 | return templ_7745c5c3_Err | |
| 1246 | 1326 | } | |
| 1247 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</option>") | |
| 1327 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "</option>") | |
| 1248 | 1328 | if templ_7745c5c3_Err != nil { | |
| 1249 | 1329 | return templ_7745c5c3_Err | |
| 1250 | 1330 | } | |
| 1251 | 1331 | } | |
| 1252 | 1332 | } | |
| 1253 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</select></label>") | |
| 1333 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "</select></label>") | |
| 1254 | 1334 | if templ_7745c5c3_Err != nil { | |
| 1255 | 1335 | return templ_7745c5c3_Err | |
| 1256 | 1336 | } | |
| @@ -1279,12 +1359,12 @@ func ShortcutSelect(selected *int) templ.Component { | |||
| 1279 | 1359 | templ_7745c5c3_Var66 = templ.NopComponent | |
| 1280 | 1360 | } | |
| 1281 | 1361 | ctx = templ.ClearChildren(ctx) | |
| 1282 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "<label><span>Shortcut</span><select name=\"shortcut_key\"><option value=\"\">None</option> ") | |
| 1362 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "<label><span>Shortcut</span><select name=\"shortcut_key\"><option value=\"\">None</option> ") | |
| 1283 | 1363 | if templ_7745c5c3_Err != nil { | |
| 1284 | 1364 | return templ_7745c5c3_Err | |
| 1285 | 1365 | } | |
| 1286 | 1366 | for i := 1; i <= 9; i++ { | |
| 1287 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "<option value=\"") | |
| 1367 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "<option value=\"") | |
| 1288 | 1368 | if templ_7745c5c3_Err != nil { | |
| 1289 | 1369 | return templ_7745c5c3_Err | |
| 1290 | 1370 | } | |
| @@ -1297,17 +1377,17 @@ func ShortcutSelect(selected *int) templ.Component { | |||
| 1297 | 1377 | if templ_7745c5c3_Err != nil { | |
| 1298 | 1378 | return templ_7745c5c3_Err | |
| 1299 | 1379 | } | |
| 1300 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "\"") | |
| 1380 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "\"") | |
| 1301 | 1381 | if templ_7745c5c3_Err != nil { | |
| 1302 | 1382 | return templ_7745c5c3_Err | |
| 1303 | 1383 | } | |
| 1304 | 1384 | if shortcutSelected(selected, i) { | |
| 1305 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, " selected") | |
| 1385 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, " selected") | |
| 1306 | 1386 | if templ_7745c5c3_Err != nil { | |
| 1307 | 1387 | return templ_7745c5c3_Err | |
| 1308 | 1388 | } | |
| 1309 | 1389 | } | |
| 1310 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, ">") | |
| 1390 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, ">") | |
| 1311 | 1391 | if templ_7745c5c3_Err != nil { | |
| 1312 | 1392 | return templ_7745c5c3_Err | |
| 1313 | 1393 | } | |
| @@ -1320,12 +1400,12 @@ func ShortcutSelect(selected *int) templ.Component { | |||
| 1320 | 1400 | if templ_7745c5c3_Err != nil { | |
| 1321 | 1401 | return templ_7745c5c3_Err | |
| 1322 | 1402 | } | |
| 1323 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "</option>") | |
| 1403 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "</option>") | |
| 1324 | 1404 | if templ_7745c5c3_Err != nil { | |
| 1325 | 1405 | return templ_7745c5c3_Err | |
| 1326 | 1406 | } | |
| 1327 | 1407 | } | |
| 1328 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "</select></label>") | |
| 1408 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "</select></label>") | |
| 1329 | 1409 | if templ_7745c5c3_Err != nil { | |
| 1330 | 1410 | return templ_7745c5c3_Err | |
| 1331 | 1411 | } | |
M internal/ui/systemviews/history.templ +107 -36
| @@ -2,6 +2,7 @@ package systemviews | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | + | "strings" | |
| 5 | 6 | "github.com/bnema/dumber/internal/domain/entity" | |
| 6 | 7 | ) | |
| 7 | 8 | ||
| @@ -28,8 +29,8 @@ templ HistoryControls(data historyRenderData) { | |||
| 28 | 29 | <span>Search</span> | |
| 29 | 30 | <input data-sv-history-search name="query" type="search" value={ data.Query } placeholder="Search title or URL" autocomplete="off"/> | |
| 30 | 31 | </label> | |
| 31 | - | <button class="sv-button" type="submit">Search</button> | |
| 32 | - | <button class="sv-button sv-button-secondary" type="button" data-sv-action="history.clear">Clear</button> | |
| 32 | + | <button class="sv-button" type="submit">@ButtonIconLabel(iconSearch, "Search")</button> | |
| 33 | + | <button class="sv-button sv-button-secondary" type="button" data-sv-action="history.clear">@ButtonIconLabel(iconX, "Clear")</button> | |
| 33 | 34 | </form> | |
| 34 | 35 | if showHistoryFilters(data) { | |
| 35 | 36 | <div class="sv-active-filters"> | |
| @@ -37,20 +38,18 @@ templ HistoryControls(data historyRenderData) { | |||
| 37 | 38 | <span class="sv-filter-chip">Query: { data.Query }</span> | |
| 38 | 39 | } | |
| 39 | 40 | if data.DomainFilter != "" { | |
| 40 | - | <span class="sv-filter-chip">Domain: { data.DomainFilter } <button type="button" data-sv-action="history.clearDomain" aria-label="Clear domain filter">×</button></span> | |
| 41 | + | <span class="sv-filter-chip">Domain: { data.DomainFilter } <button type="button" data-sv-action="history.clearDomain" aria-label="Clear domain filter">@IconOnly(iconX, "Clear domain filter")</button></span> | |
| 41 | 42 | } | |
| 42 | 43 | </div> | |
| 43 | 44 | } | |
| 44 | 45 | <div class="sv-history-actions-panel"> | |
| 45 | 46 | <div class="sv-button-row" aria-label="History pagination"> | |
| 46 | - | <button type="button" class="sv-button sv-button-secondary" data-sv-action="history.page" data-offset={ fmt.Sprintf("%d", previousHistoryOffset(data)) } disabled?={ disableHistoryPrev(data) }>Previous</button> | |
| 47 | - | <span class="sv-meta">Showing { fmt.Sprintf("%d", countHistoryEntries(data.Entries)) } item{ pluralSuffix(countHistoryEntries(data.Entries)) }{ historyOffsetLabel(data.Offset, data.Query) }</span> | |
| 48 | - | <button type="button" class="sv-button sv-button-secondary" data-sv-action="history.page" data-offset={ fmt.Sprintf("%d", nextHistoryOffset(data)) } disabled?={ disableHistoryNext(data) }>Next</button> | |
| 47 | + | <span class="sv-meta">{ historyShowingLabel(data) }</span> | |
| 49 | 48 | </div> | |
| 50 | 49 | <div class="sv-button-row" aria-label="History cleanup"> | |
| 51 | 50 | <span class="sv-meta">Clean up</span> | |
| 52 | 51 | for _, item := range historyCleanupItems() { | |
| 53 | - | <button type="button" class="sv-button sv-button-secondary sv-button-danger" data-sv-action="history.deleteRange" data-range={ item.RangeID } data-sv-confirm={ item.Confirm }>{ item.Label }</button> | |
| 52 | + | <button type="button" class="sv-danger-link" data-sv-action="history.deleteRange" data-range={ item.RangeID } data-sv-confirm={ item.Confirm }>{ item.Label }</button> | |
| 54 | 53 | } | |
| 55 | 54 | </div> | |
| 56 | 55 | </div> | |
| @@ -58,20 +57,31 @@ templ HistoryControls(data historyRenderData) { | |||
| 58 | 57 | } | |
| 59 | 58 | ||
| 60 | 59 | templ HistorySummary(data historyRenderData) { | |
| 61 | - | {{ entries, visits, days := historySummaryValues(data) }} | |
| 62 | - | <div class="sv-stat-grid"> | |
| 63 | - | @HistoryStatCard("Entries", entries) | |
| 64 | - | @HistoryStatCard("Visits", visits) | |
| 65 | - | @HistoryStatCard("Days", days) | |
| 66 | - | </div> | |
| 60 | + | if historySummaryLoading(data) { | |
| 61 | + | <div class="sv-stat-grid"> | |
| 62 | + | @LoadingCard("Entries") | |
| 63 | + | @LoadingCard("Visits") | |
| 64 | + | @LoadingCard("Days") | |
| 65 | + | </div> | |
| 66 | + | } else { | |
| 67 | + | {{ entries, visits, days := historySummaryValues(data) }} | |
| 68 | + | <div class="sv-stat-grid"> | |
| 69 | + | @HistoryStatCard("Entries", entries) | |
| 70 | + | @HistoryStatCard("Visits", visits) | |
| 71 | + | @HistoryStatCard("Days", days) | |
| 72 | + | </div> | |
| 73 | + | } | |
| 67 | 74 | } | |
| 68 | 75 | ||
| 76 | + | ||
| 69 | 77 | templ HistoryStatCard(label string, value int64) { | |
| 70 | 78 | <div class="sv-stat-card"><span class="sv-stat-value">{ fmt.Sprintf("%d", value) }</span><span class="sv-stat-label">{ label }</span></div> | |
| 71 | 79 | } | |
| 72 | 80 | ||
| 73 | 81 | templ HistoryDomains(data historyRenderData) { | |
| 74 | - | if len(data.Domains) == 0 { | |
| 82 | + | if data.Loading { | |
| 83 | + | @LoadingPanel("Loading top domains…") | |
| 84 | + | } else if len(data.Domains) == 0 { | |
| 75 | 85 | @EmptyState("No domain statistics yet") | |
| 76 | 86 | } else { | |
| 77 | 87 | <div class="sv-domain-list"> | |
| @@ -80,9 +90,11 @@ templ HistoryDomains(data historyRenderData) { | |||
| 80 | 90 | {{ domainKey := historyDomainActionKey(domain) }} | |
| 81 | 91 | {{ displayDomain := historyDomainDisplayLabel(domain) }} | |
| 82 | 92 | <span class="sv-domain-chip"> | |
| 83 | - | <button type="button" class="sv-domain-filter" data-sv-action="history.filterDomain" data-domain={ domainKey }>{ displayDomain }</button> | |
| 84 | - | <span class="sv-domain-meta">{ fmt.Sprintf("%d pages · %d visits", domain.PageCount, domain.TotalVisits) }</span> | |
| 85 | - | <button type="button" class="sv-icon-button sv-danger" data-sv-action="history.deleteDomain" data-domain={ domainKey } data-sv-confirm={ "Delete all history for " + displayDomain + "?" } aria-label={ "Delete history for " + displayDomain }>×</button> | |
| 93 | + | <span class="sv-domain-chip-main"> | |
| 94 | + | <button type="button" class="sv-domain-filter" data-sv-action="history.filterDomain" data-domain={ domainKey }>{ displayDomain }</button> | |
| 95 | + | <span class="sv-domain-meta">{ fmt.Sprintf("%d pages · %d visits", domain.PageCount, domain.TotalVisits) }</span> | |
| 96 | + | </span> | |
| 97 | + | <button type="button" class="sv-icon-button sv-danger" data-sv-action="history.deleteDomain" data-domain={ domainKey } data-sv-confirm={ "Delete all history for " + displayDomain + "?" } aria-label={ "Delete history for " + displayDomain }>@IconOnly(iconTrash, "Delete history for " + displayDomain)</button> | |
| 86 | 98 | </span> | |
| 87 | 99 | } | |
| 88 | 100 | } | |
| @@ -91,36 +103,95 @@ templ HistoryDomains(data historyRenderData) { | |||
| 91 | 103 | } | |
| 92 | 104 | ||
| 93 | 105 | templ HistoryTimeline(data historyRenderData) { | |
| 94 | - | {{ groups := groupHistoryEntries(data.Entries) }} | |
| 95 | - | if len(groups) == 0 { | |
| 96 | - | @EmptyState(historyTimelineEmptyMessage(data)) | |
| 106 | + | if data.Loading { | |
| 107 | + | @LoadingPanel("Loading recent visits…") | |
| 97 | 108 | } else { | |
| 98 | - | for _, group := range groups { | |
| 99 | - | <section class="sv-timeline-group"> | |
| 100 | - | <h3>{ group.Label }</h3> | |
| 101 | - | <ul class="sv-list"> | |
| 102 | - | for _, entry := range group.Entries { | |
| 103 | - | if entry != nil { | |
| 104 | - | @HistoryItem(entry) | |
| 105 | - | } | |
| 109 | + | <div class="sv-history-timeline-list" data-sv-history-timeline> | |
| 110 | + | {{ groups := groupHistoryEntries(data.Entries) }} | |
| 111 | + | if len(groups) == 0 { | |
| 112 | + | @EmptyState(historyTimelineEmptyMessage(data)) | |
| 113 | + | } else { | |
| 114 | + | @HistoryTimelineGroups(data.Entries, "") | |
| 115 | + | } | |
| 116 | + | @HistoryLoadMore(data) | |
| 117 | + | </div> | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | templ HistoryTimelineAppend(data historyRenderData) { | |
| 122 | + | @HistoryTimelineAppendGroups(data.Entries, data.AppendSkipFirstDate) | |
| 123 | + | @HistoryLoadMore(data) | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | templ HistoryTimelineGroups(entries []*entity.HistoryEntry, skipFirstDate string) { | |
| 127 | + | {{ groups := groupHistoryEntries(entries) }} | |
| 128 | + | for _, group := range groups { | |
| 129 | + | @HistoryTimelineGroup(group, true) | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | templ HistoryTimelineAppendGroups(entries []*entity.HistoryEntry, mergeFirstDate string) { | |
| 134 | + | {{ groups := groupHistoryEntries(entries) }} | |
| 135 | + | for index, group := range groups { | |
| 136 | + | if index == 0 && group.Date == mergeFirstDate { | |
| 137 | + | <div data-sv-history-merge-date={ group.Date }> | |
| 138 | + | for _, entry := range group.Entries { | |
| 139 | + | if entry != nil { | |
| 140 | + | @HistoryItem(entry) | |
| 106 | 141 | } | |
| 107 | - | </ul> | |
| 108 | - | </section> | |
| 142 | + | } | |
| 143 | + | </div> | |
| 144 | + | } else { | |
| 145 | + | @HistoryTimelineGroup(group, true) | |
| 109 | 146 | } | |
| 110 | 147 | } | |
| 111 | 148 | } | |
| 112 | 149 | ||
| 150 | + | templ HistoryTimelineGroup(group historyTimelineGroup, showHeading bool) { | |
| 151 | + | <section class="sv-timeline-group" data-sv-history-date={ group.Date }> | |
| 152 | + | if showHeading { | |
| 153 | + | <h3>{ group.Label }</h3> | |
| 154 | + | } | |
| 155 | + | <ul class="sv-list"> | |
| 156 | + | for _, entry := range group.Entries { | |
| 157 | + | if entry != nil { | |
| 158 | + | @HistoryItem(entry) | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | </ul> | |
| 162 | + | </section> | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | templ HistoryLoadMore(data historyRenderData) { | |
| 166 | + | <div class="sv-history-load-more" data-sv-history-load-more-container> | |
| 167 | + | if showHistoryLoadMore(data) { | |
| 168 | + | <button type="button" class="sv-button sv-button-secondary" data-sv-action="history.loadMore" data-before={ historyLoadMoreCursor(data) } data-sv-history-load-more>Load older visits</button> | |
| 169 | + | } else if strings.TrimSpace(data.Query) == "" && countHistoryEntries(data.Entries) > 0 { | |
| 170 | + | <p class="sv-meta">End of history</p> | |
| 171 | + | } | |
| 172 | + | </div> | |
| 173 | + | } | |
| 174 | + | ||
| 113 | 175 | templ HistoryItem(entry *entity.HistoryEntry) { | |
| 114 | 176 | <li class="sv-list-row"> | |
| 115 | 177 | <article class="sv-history-item" data-sv-history-row data-history-id={ fmt.Sprintf("%d", entry.ID) }> | |
| 116 | - | <div class="sv-history-main"> | |
| 117 | - | <a class="sv-link sv-history-open" href={ templ.SafeURL(sanitizeHref(entry.URL)) }>{ historyItemLabel(entry) }</a> | |
| 118 | - | <p class="sv-history-url">{ entry.URL }</p> | |
| 119 | - | <p class="sv-history-meta">{ historyItemMeta(entry) }</p> | |
| 178 | + | <div class="sv-history-content"> | |
| 179 | + | {{ faviconURL := historyItemFaviconURL(entry) }} | |
| 180 | + | <span class={ historyFaviconClass(faviconURL) } aria-hidden="true"> | |
| 181 | + | <span class="sv-history-favicon-fallback">{ historyFaviconFallback(entry) }</span> | |
| 182 | + | if faviconURL != "" { | |
| 183 | + | <img src={ templ.SafeURL(faviconURL) } alt="" width="24" height="24" loading="lazy" decoding="async" referrerpolicy="same-origin" onerror="this.parentElement.classList.remove('sv-history-favicon-has-image'); this.remove()"/> | |
| 184 | + | } | |
| 185 | + | </span> | |
| 186 | + | <div class="sv-history-main"> | |
| 187 | + | <a class="sv-link sv-history-open" href={ templ.SafeURL(sanitizeHref(entry.URL)) }>{ historyItemLabel(entry) }</a> | |
| 188 | + | <p class="sv-history-url" title={ entry.URL }>{ historyItemURL(entry) }</p> | |
| 189 | + | <p class="sv-history-meta">{ historyItemMeta(entry) }</p> | |
| 190 | + | </div> | |
| 120 | 191 | </div> | |
| 121 | 192 | <div class="sv-history-row-actions"> | |
| 122 | - | <a class="sv-button sv-button-secondary" href={ templ.SafeURL(sanitizeHref(entry.URL)) }>Open</a> | |
| 123 | - | <button type="button" class="sv-button sv-button-secondary sv-button-danger" data-sv-action="history.deleteEntry" data-id={ fmt.Sprintf("%d", entry.ID) } data-sv-confirm="Delete this history entry?" disabled?={ entry.ID <= 0 }>Delete</button> | |
| 193 | + | <a class="sv-button sv-button-secondary" href={ templ.SafeURL(sanitizeHref(entry.URL)) }>@ButtonIconLabel(iconExternal, "Open")</a> | |
| 194 | + | <button type="button" class="sv-button sv-button-secondary sv-button-danger sv-button-icon-only" data-sv-action="history.deleteEntry" data-id={ fmt.Sprintf("%d", entry.ID) } data-sv-confirm="Delete this history entry?" aria-label="Delete this history entry" disabled?={ entry.ID <= 0 }>@IconOnly(iconTrash, "Delete this history entry")</button> | |
| 124 | 195 | </div> | |
| 125 | 196 | </article> | |
| 126 | 197 | </li> | |
M internal/ui/systemviews/history_actions.go +108 -9
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "fmt" | |
| 6 | 6 | "strconv" | |
| 7 | 7 | "strings" | |
| 8 | + | "time" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| 10 | 11 | const ( | |
| @@ -13,6 +14,7 @@ const ( | |||
| 13 | 14 | historyActionFilterDomain = "history.filterDomain" | |
| 14 | 15 | historyActionClearDomain = "history.clearDomain" | |
| 15 | 16 | historyActionPage = "history.page" | |
| 17 | + | historyActionLoadMore = "history.loadMore" | |
| 16 | 18 | historyActionDeleteEntry = "history.deleteEntry" | |
| 17 | 19 | historyActionDeleteRange = "history.deleteRange" | |
| 18 | 20 | historyActionDeleteDomain = "history.deleteDomain" | |
| @@ -26,12 +28,17 @@ func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error { | |||
| 26 | 28 | return fmt.Errorf("app is nil") | |
| 27 | 29 | } | |
| 28 | 30 | a.lockState() | |
| 29 | - | defer a.unlockState() | |
| 30 | 31 | if a.currentRoute == RouteUnknown || a.currentRoute == "" { | |
| 31 | 32 | a.currentRoute = ParseRoute(a.deps.LocationURI) | |
| 32 | 33 | } | |
| 34 | + | route := a.currentRoute | |
| 35 | + | if route == RouteHistory && event.Action == historyActionLoadMore { | |
| 36 | + | a.unlockState() | |
| 37 | + | return a.handleHistoryLoadMore(ctx, event) | |
| 38 | + | } | |
| 39 | + | defer a.unlockState() | |
| 33 | 40 | ||
| 34 | - | switch a.currentRoute { | |
| 41 | + | switch route { | |
| 35 | 42 | case RouteHistory: | |
| 36 | 43 | a.historyError = "" | |
| 37 | 44 | if err := a.handleHistoryAction(ctx, event); err != nil { | |
| @@ -84,12 +91,12 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error { | |||
| 84 | 91 | case historyActionSearch: | |
| 85 | 92 | a.historyQuery = strings.TrimSpace(data["query"]) | |
| 86 | 93 | a.historyDomainFilter = "" | |
| 87 | - | a.historyOffset = 0 | |
| 94 | + | a.resetHistoryWindowState() | |
| 88 | 95 | a.historyNotice = "" | |
| 89 | 96 | case historyActionClear: | |
| 90 | 97 | a.historyQuery = "" | |
| 91 | 98 | a.historyDomainFilter = "" | |
| 92 | - | a.historyOffset = 0 | |
| 99 | + | a.resetHistoryWindowState() | |
| 93 | 100 | a.historyNotice = "" | |
| 94 | 101 | case historyActionFilterDomain: | |
| 95 | 102 | domain := strings.TrimSpace(data["domain"]) | |
| @@ -98,17 +105,18 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error { | |||
| 98 | 105 | } | |
| 99 | 106 | a.historyDomainFilter = domain | |
| 100 | 107 | a.historyQuery = "" | |
| 101 | - | a.historyOffset = 0 | |
| 108 | + | a.resetHistoryWindowState() | |
| 102 | 109 | a.historyNotice = "" | |
| 103 | 110 | case historyActionClearDomain: | |
| 104 | 111 | a.historyDomainFilter = "" | |
| 105 | - | a.historyOffset = 0 | |
| 112 | + | a.resetHistoryWindowState() | |
| 106 | 113 | a.historyNotice = "" | |
| 107 | 114 | case historyActionPage: | |
| 108 | 115 | offset, err := strconv.Atoi(strings.TrimSpace(data["offset"])) | |
| 109 | 116 | if err != nil || offset < 0 { | |
| 110 | 117 | return fmt.Errorf("invalid history offset") | |
| 111 | 118 | } | |
| 119 | + | a.resetHistoryWindowState() | |
| 112 | 120 | a.historyOffset = offset | |
| 113 | 121 | a.historyNotice = "" | |
| 114 | 122 | case historyActionDeleteEntry: | |
| @@ -119,7 +127,7 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error { | |||
| 119 | 127 | if err := a.deps.History.DeleteEntry(ctx, id); err != nil { | |
| 120 | 128 | return err | |
| 121 | 129 | } | |
| 122 | - | a.historyOffset = 0 | |
| 130 | + | a.resetHistoryWindowState() | |
| 123 | 131 | a.historyNotice = "Deleted history entry" | |
| 124 | 132 | case historyActionDeleteRange: | |
| 125 | 133 | rangeID := strings.TrimSpace(data["range"]) | |
| @@ -132,7 +140,7 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error { | |||
| 132 | 140 | if err := a.deps.History.DeleteRange(ctx, rangeID); err != nil { | |
| 133 | 141 | return err | |
| 134 | 142 | } | |
| 135 | - | a.historyOffset = 0 | |
| 143 | + | a.resetHistoryWindowState() | |
| 136 | 144 | a.historyNotice = historyRangeNotice(rangeID) | |
| 137 | 145 | case historyActionDeleteDomain: | |
| 138 | 146 | domain := strings.TrimSpace(data["domain"]) | |
| @@ -145,7 +153,7 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error { | |||
| 145 | 153 | if strings.EqualFold(a.historyDomainFilter, domain) { | |
| 146 | 154 | a.historyDomainFilter = "" | |
| 147 | 155 | } | |
| 148 | - | a.historyOffset = 0 | |
| 156 | + | a.resetHistoryWindowState() | |
| 149 | 157 | a.historyNotice = "Deleted history for " + domain | |
| 150 | 158 | default: | |
| 151 | 159 | return fmt.Errorf("unknown history action: %q", event.Action) | |
| @@ -153,6 +161,97 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error { | |||
| 153 | 161 | return nil | |
| 154 | 162 | } | |
| 155 | 163 | ||
| 164 | + | func (a *App) handleHistoryLoadMore(ctx context.Context, event DOMAction) error { | |
| 165 | + | a.lockState() | |
| 166 | + | if a.deps.History == nil { | |
| 167 | + | a.unlockState() | |
| 168 | + | return fmt.Errorf("history service not configured") | |
| 169 | + | } | |
| 170 | + | if strings.TrimSpace(a.historyQuery) != "" || !a.historyHasMore { | |
| 171 | + | a.unlockState() | |
| 172 | + | return nil | |
| 173 | + | } | |
| 174 | + | before := a.historyWindowAfter | |
| 175 | + | if requested := strings.TrimSpace(event.Data["before"]); requested != "" { | |
| 176 | + | parsed, err := time.Parse(time.RFC3339Nano, requested) | |
| 177 | + | if err != nil { | |
| 178 | + | a.unlockState() | |
| 179 | + | return fmt.Errorf("invalid history cursor") | |
| 180 | + | } | |
| 181 | + | if !before.IsZero() && !parsed.Equal(before) { | |
| 182 | + | a.unlockState() | |
| 183 | + | return nil | |
| 184 | + | } | |
| 185 | + | before = parsed | |
| 186 | + | } | |
| 187 | + | if before.IsZero() { | |
| 188 | + | before = time.Now() | |
| 189 | + | } | |
| 190 | + | domain := a.historyDomainFilter | |
| 191 | + | history := a.deps.History | |
| 192 | + | dom := a.deps.DOM | |
| 193 | + | a.unlockState() | |
| 194 | + | ||
| 195 | + | window, err := history.TimelineWindow(ctx, before, domain) | |
| 196 | + | if err != nil { | |
| 197 | + | return err | |
| 198 | + | } | |
| 199 | + | if window == nil { | |
| 200 | + | a.lockState() | |
| 201 | + | a.historyHasMore = false | |
| 202 | + | a.unlockState() | |
| 203 | + | return nil | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | a.lockState() | |
| 207 | + | appendSkipFirstDate := lastHistoryDateKey(a.historyEntries) | |
| 208 | + | a.historyEntries = append(a.historyEntries, window.Entries...) | |
| 209 | + | a.historyWindowBefore = window.Before | |
| 210 | + | a.historyWindowAfter = window.After | |
| 211 | + | a.historyHasMore = window.HasMore | |
| 212 | + | data := historyRenderData{ | |
| 213 | + | Entries: window.Entries, | |
| 214 | + | Stats: a.historyStats, | |
| 215 | + | Domains: a.historyDomainStats, | |
| 216 | + | Query: a.historyQuery, | |
| 217 | + | DomainFilter: a.historyDomainFilter, | |
| 218 | + | Offset: a.historyOffset, | |
| 219 | + | Limit: historyTimelineLimit, | |
| 220 | + | WindowBefore: a.historyWindowBefore, | |
| 221 | + | WindowAfter: a.historyWindowAfter, | |
| 222 | + | HasMore: a.historyHasMore, | |
| 223 | + | AppendSkipFirstDate: appendSkipFirstDate, | |
| 224 | + | Notice: a.historyNotice, | |
| 225 | + | Error: a.historyError, | |
| 226 | + | } | |
| 227 | + | fragment := historyTimelineAppendHTML(data) | |
| 228 | + | var fallbackHTML string | |
| 229 | + | if _, ok := dom.(DOMHistoryTimelineAppender); !ok { | |
| 230 | + | fullData := data | |
| 231 | + | fullData.Entries = a.historyEntries | |
| 232 | + | a.renderedHTML = renderAppFrame(renderedPage{ | |
| 233 | + | route: RouteHistory, | |
| 234 | + | title: historyDocumentTitle(fullData), | |
| 235 | + | subtitle: "Recent visits", | |
| 236 | + | body: historyHTML(fullData), | |
| 237 | + | }, a.shellTheme) | |
| 238 | + | fallbackHTML = a.renderedHTML | |
| 239 | + | } | |
| 240 | + | a.unlockState() | |
| 241 | + | ||
| 242 | + | if appender, ok := dom.(DOMHistoryTimelineAppender); ok { | |
| 243 | + | return appender.AppendHistoryTimeline(fragment) | |
| 244 | + | } | |
| 245 | + | return a.mountHTML(fallbackHTML) | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | func (a *App) resetHistoryWindowState() { | |
| 249 | + | a.historyOffset = 0 | |
| 250 | + | a.historyWindowBefore = time.Time{} | |
| 251 | + | a.historyWindowAfter = time.Time{} | |
| 252 | + | a.historyHasMore = false | |
| 253 | + | } | |
| 254 | + | ||
| 156 | 255 | func (a *App) mountRenderedHTML() error { | |
| 157 | 256 | return a.mountHTML(a.renderedHTML) | |
| 158 | 257 | } | |
M internal/ui/systemviews/history_templ.go +564 -228 generated file: collapsed
M internal/ui/systemviews/history_view.go +140 -19
| @@ -2,6 +2,7 @@ package systemviews | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | + | "net/url" | |
| 5 | 6 | "strings" | |
| 6 | 7 | "time" | |
| 7 | 8 | ||
| @@ -9,16 +10,24 @@ import ( | |||
| 9 | 10 | browserurl "github.com/bnema/dumber/internal/domain/url" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| 13 | + | const historyURLDisplayMaxRunes = 180 | |
| 14 | + | ||
| 12 | 15 | type historyRenderData struct { | |
| 13 | - | Entries []*entity.HistoryEntry | |
| 14 | - | Analytics *entity.HistoryAnalytics | |
| 15 | - | Domains []*entity.DomainStat | |
| 16 | - | Query string | |
| 17 | - | DomainFilter string | |
| 18 | - | Offset int | |
| 19 | - | Limit int | |
| 20 | - | Notice string | |
| 21 | - | Error string | |
| 16 | + | Entries []*entity.HistoryEntry | |
| 17 | + | Stats *entity.HistoryStats | |
| 18 | + | Analytics *entity.HistoryAnalytics | |
| 19 | + | Domains []*entity.DomainStat | |
| 20 | + | Query string | |
| 21 | + | DomainFilter string | |
| 22 | + | Offset int | |
| 23 | + | Limit int | |
| 24 | + | WindowBefore time.Time | |
| 25 | + | WindowAfter time.Time | |
| 26 | + | HasMore bool | |
| 27 | + | AppendSkipFirstDate string | |
| 28 | + | Notice string | |
| 29 | + | Error string | |
| 30 | + | Loading bool | |
| 22 | 31 | } | |
| 23 | 32 | ||
| 24 | 33 | type historyTimelineGroup struct { | |
| @@ -38,7 +47,14 @@ func historyHTML(data historyRenderData) string { | |||
| 38 | 47 | return mustRenderComponent(HistoryView(data)) | |
| 39 | 48 | } | |
| 40 | 49 | ||
| 50 | + | func historyTimelineAppendHTML(data historyRenderData) string { | |
| 51 | + | return mustRenderComponent(HistoryTimelineAppend(data)) | |
| 52 | + | } | |
| 53 | + | ||
| 41 | 54 | func historyDocumentTitle(data historyRenderData) string { | |
| 55 | + | if data.Loading { | |
| 56 | + | return "History — Loading" | |
| 57 | + | } | |
| 42 | 58 | if query := strings.TrimSpace(data.Query); query != "" { | |
| 43 | 59 | return "History — search: " + truncateTitle(query, 48) | |
| 44 | 60 | } | |
| @@ -51,7 +67,9 @@ func historyDocumentTitle(data historyRenderData) string { | |||
| 51 | 67 | } | |
| 52 | 68 | ||
| 53 | 69 | entries := int64(countHistoryEntries(data.Entries)) | |
| 54 | - | if data.Analytics != nil { | |
| 70 | + | if data.Stats != nil { | |
| 71 | + | entries = data.Stats.TotalEntries | |
| 72 | + | } else if data.Analytics != nil { | |
| 55 | 73 | entries = data.Analytics.TotalEntries | |
| 56 | 74 | } | |
| 57 | 75 | if entries == 1 { | |
| @@ -82,12 +100,19 @@ func historyCleanupItems() []historyCleanupItem { | |||
| 82 | 100 | ||
| 83 | 101 | func historyLimit(data historyRenderData) int { | |
| 84 | 102 | if data.Limit <= 0 { | |
| 85 | - | return historyTimelineLimit | |
| 103 | + | return 0 | |
| 86 | 104 | } | |
| 87 | 105 | return data.Limit | |
| 88 | 106 | } | |
| 89 | 107 | ||
| 108 | + | func historyIsPaginated(data historyRenderData) bool { | |
| 109 | + | return historyLimit(data) > 0 | |
| 110 | + | } | |
| 111 | + | ||
| 90 | 112 | func previousHistoryOffset(data historyRenderData) int { | |
| 113 | + | if !historyIsPaginated(data) { | |
| 114 | + | return 0 | |
| 115 | + | } | |
| 91 | 116 | offset := data.Offset - historyLimit(data) | |
| 92 | 117 | if offset < 0 { | |
| 93 | 118 | return 0 | |
| @@ -96,29 +121,36 @@ func previousHistoryOffset(data historyRenderData) int { | |||
| 96 | 121 | } | |
| 97 | 122 | ||
| 98 | 123 | func nextHistoryOffset(data historyRenderData) int { | |
| 124 | + | if !historyIsPaginated(data) { | |
| 125 | + | return 0 | |
| 126 | + | } | |
| 99 | 127 | return max(data.Offset, 0) + historyLimit(data) | |
| 100 | 128 | } | |
| 101 | 129 | ||
| 102 | 130 | func disableHistoryPrev(data historyRenderData) bool { | |
| 103 | - | return data.Offset <= 0 || strings.TrimSpace(data.Query) != "" | |
| 131 | + | return !historyIsPaginated(data) || data.Offset <= 0 || strings.TrimSpace(data.Query) != "" | |
| 104 | 132 | } | |
| 105 | 133 | ||
| 106 | 134 | func disableHistoryNext(data historyRenderData) bool { | |
| 107 | - | return strings.TrimSpace(data.Query) != "" || countHistoryEntries(data.Entries) < historyLimit(data) | |
| 135 | + | return !historyIsPaginated(data) || strings.TrimSpace(data.Query) != "" || countHistoryEntries(data.Entries) < historyLimit(data) | |
| 108 | 136 | } | |
| 109 | 137 | ||
| 110 | 138 | func showHistoryFilters(data historyRenderData) bool { | |
| 111 | 139 | return strings.TrimSpace(data.Query) != "" || strings.TrimSpace(data.DomainFilter) != "" | |
| 112 | 140 | } | |
| 113 | 141 | ||
| 114 | - | func historyOffsetLabel(offset int, query string) string { | |
| 115 | - | if strings.TrimSpace(query) != "" { | |
| 116 | - | return " matching search" | |
| 142 | + | func historyShowingLabel(data historyRenderData) string { | |
| 143 | + | if data.Loading { | |
| 144 | + | return "Loading history…" | |
| 117 | 145 | } | |
| 118 | - | if offset <= 0 { | |
| 119 | - | return "" | |
| 146 | + | count := countHistoryEntries(data.Entries) | |
| 147 | + | if strings.TrimSpace(data.Query) != "" { | |
| 148 | + | return fmt.Sprintf("Showing %d matching item%s", count, pluralSuffix(count)) | |
| 149 | + | } | |
| 150 | + | if strings.TrimSpace(data.DomainFilter) != "" { | |
| 151 | + | return fmt.Sprintf("Loaded %d domain item%s", count, pluralSuffix(count)) | |
| 120 | 152 | } | |
| 121 | - | return fmt.Sprintf(" from offset %d", offset) | |
| 153 | + | return fmt.Sprintf("Loaded %d item%s", count, pluralSuffix(count)) | |
| 122 | 154 | } | |
| 123 | 155 | ||
| 124 | 156 | func pluralSuffix(count int) string { | |
| @@ -128,7 +160,28 @@ func pluralSuffix(count int) string { | |||
| 128 | 160 | return "s" | |
| 129 | 161 | } | |
| 130 | 162 | ||
| 163 | + | func historyLoadMoreCursor(data historyRenderData) string { | |
| 164 | + | if data.WindowAfter.IsZero() { | |
| 165 | + | return "" | |
| 166 | + | } | |
| 167 | + | return data.WindowAfter.Format(time.RFC3339Nano) | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | func showHistoryLoadMore(data historyRenderData) bool { | |
| 171 | + | return !data.Loading && strings.TrimSpace(data.Query) == "" && data.HasMore | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func historySummaryLoading(data historyRenderData) bool { | |
| 175 | + | return data.Loading | |
| 176 | + | } | |
| 177 | + | ||
| 131 | 178 | func historySummaryValues(data historyRenderData) (entries, visits, uniqueDays int64) { | |
| 179 | + | if data.Stats != nil { | |
| 180 | + | return data.Stats.TotalEntries, data.Stats.TotalVisits, data.Stats.UniqueDays | |
| 181 | + | } | |
| 182 | + | if data.Analytics != nil { | |
| 183 | + | return data.Analytics.TotalEntries, data.Analytics.TotalVisits, data.Analytics.UniqueDays | |
| 184 | + | } | |
| 132 | 185 | entries = int64(countHistoryEntries(data.Entries)) | |
| 133 | 186 | visits = sumHistoryVisits(data.Entries) | |
| 134 | 187 | uniqueDays = countUniqueHistoryDays(data.Entries) | |
| @@ -137,12 +190,26 @@ func historySummaryValues(data historyRenderData) (entries, visits, uniqueDays i | |||
| 137 | 190 | } | |
| 138 | 191 | ||
| 139 | 192 | func historyTimelineEmptyMessage(data historyRenderData) string { | |
| 193 | + | if data.Loading { | |
| 194 | + | return "Loading history…" | |
| 195 | + | } | |
| 140 | 196 | if strings.TrimSpace(data.Query) != "" || strings.TrimSpace(data.DomainFilter) != "" { | |
| 141 | 197 | return "No history entries match the current filters" | |
| 142 | 198 | } | |
| 143 | 199 | return "No history entries" | |
| 144 | 200 | } | |
| 145 | 201 | ||
| 202 | + | func lastHistoryDateKey(entries []*entity.HistoryEntry) string { | |
| 203 | + | for i := len(entries) - 1; i >= 0; i-- { | |
| 204 | + | if entries[i] == nil { | |
| 205 | + | continue | |
| 206 | + | } | |
| 207 | + | dateKey, _ := historyDateKeyAndLabel(entries[i].LastVisited, time.Now().Local()) | |
| 208 | + | return dateKey | |
| 209 | + | } | |
| 210 | + | return "" | |
| 211 | + | } | |
| 212 | + | ||
| 146 | 213 | func groupHistoryEntries(entries []*entity.HistoryEntry) []historyTimelineGroup { | |
| 147 | 214 | groups := make([]historyTimelineGroup, 0) | |
| 148 | 215 | indexByDate := map[string]int{} | |
| @@ -262,6 +329,60 @@ func historyItemLabel(entry *entity.HistoryEntry) string { | |||
| 262 | 329 | return label | |
| 263 | 330 | } | |
| 264 | 331 | ||
| 332 | + | func historyItemURL(entry *entity.HistoryEntry) string { | |
| 333 | + | if entry == nil { | |
| 334 | + | return "" | |
| 335 | + | } | |
| 336 | + | return truncateHistoryURL(entry.URL) | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | func historyItemFaviconURL(entry *entity.HistoryEntry) string { | |
| 340 | + | if entry == nil { | |
| 341 | + | return "" | |
| 342 | + | } | |
| 343 | + | domain := browserurl.CanonicalDomain(entry.URL) | |
| 344 | + | if domain == "" { | |
| 345 | + | return "" | |
| 346 | + | } | |
| 347 | + | query := url.Values{} | |
| 348 | + | query.Set("domain", domain) | |
| 349 | + | query.Set("size", "32") | |
| 350 | + | return "/api/favicon?" + query.Encode() | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | func historyFaviconClass(faviconURL string) string { | |
| 354 | + | if strings.TrimSpace(faviconURL) == "" { | |
| 355 | + | return "sv-history-favicon" | |
| 356 | + | } | |
| 357 | + | return "sv-history-favicon sv-history-favicon-has-image" | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | func historyFaviconFallback(entry *entity.HistoryEntry) string { | |
| 361 | + | label := "" | |
| 362 | + | if entry != nil { | |
| 363 | + | label = displayHistoryDomain(entry.URL) | |
| 364 | + | } | |
| 365 | + | if label == "" { | |
| 366 | + | return "•" | |
| 367 | + | } | |
| 368 | + | for _, r := range label { | |
| 369 | + | return strings.ToUpper(string(r)) | |
| 370 | + | } | |
| 371 | + | return "•" | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | func truncateHistoryURL(raw string) string { | |
| 375 | + | raw = strings.TrimSpace(raw) | |
| 376 | + | if raw == "" { | |
| 377 | + | return "" | |
| 378 | + | } | |
| 379 | + | runes := []rune(raw) | |
| 380 | + | if len(runes) <= historyURLDisplayMaxRunes { | |
| 381 | + | return raw | |
| 382 | + | } | |
| 383 | + | return string(runes[:historyURLDisplayMaxRunes-1]) + "…" | |
| 384 | + | } | |
| 385 | + | ||
| 265 | 386 | func historyItemMeta(entry *entity.HistoryEntry) string { | |
| 266 | 387 | if entry == nil { | |
| 267 | 388 | return "" | |
M internal/ui/systemviews/history_view_test.go +89 -0
| @@ -1,9 +1,11 @@ | |||
| 1 | 1 | package systemviews | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "strings" | |
| 4 | 5 | "testing" | |
| 5 | 6 | "time" | |
| 6 | 7 | ||
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 7 | 9 | "github.com/stretchr/testify/assert" | |
| 8 | 10 | ) | |
| 9 | 11 | ||
| @@ -24,3 +26,90 @@ func TestHistoryDateKeyAndLabelUsesProvidedNow(t *testing.T) { | |||
| 24 | 26 | assert.Equal(t, "Yesterday", yesterdayLabel) | |
| 25 | 27 | assert.Equal(t, "Mon Apr 20", olderLabel) | |
| 26 | 28 | } | |
| 29 | + | ||
| 30 | + | func TestHistorySummaryValuesUsesAllProvidedEntries(t *testing.T) { | |
| 31 | + | t.Parallel() | |
| 32 | + | ||
| 33 | + | loc := time.FixedZone("test", 2*60*60) | |
| 34 | + | data := historyRenderData{Entries: []*entity.HistoryEntry{ | |
| 35 | + | {VisitCount: 2, LastVisited: time.Date(2026, 4, 23, 8, 0, 0, 0, loc)}, | |
| 36 | + | {VisitCount: 3, LastVisited: time.Date(2026, 4, 24, 8, 0, 0, 0, loc)}, | |
| 37 | + | }} | |
| 38 | + | ||
| 39 | + | entries, visits, days := historySummaryValues(data) | |
| 40 | + | ||
| 41 | + | assert.Equal(t, int64(2), entries) | |
| 42 | + | assert.Equal(t, int64(5), visits) | |
| 43 | + | assert.Equal(t, int64(2), days) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestHistoryShowingLabelDescribesUnpaginatedDefault(t *testing.T) { | |
| 47 | + | t.Parallel() | |
| 48 | + | ||
| 49 | + | data := historyRenderData{Entries: []*entity.HistoryEntry{{}, {}}, Limit: 0} | |
| 50 | + | ||
| 51 | + | assert.Equal(t, "Loaded 2 items", historyShowingLabel(data)) | |
| 52 | + | assert.True(t, disableHistoryNext(data)) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func TestHistoryTimelineAppendMergesDuplicateFirstDayGroup(t *testing.T) { | |
| 56 | + | t.Parallel() | |
| 57 | + | ||
| 58 | + | loc := time.FixedZone("test", 2*60*60) | |
| 59 | + | data := historyRenderData{ | |
| 60 | + | Entries: []*entity.HistoryEntry{ | |
| 61 | + | {URL: "https://same.example", Title: "Same day", LastVisited: time.Date(2026, 4, 23, 8, 0, 0, 0, loc)}, | |
| 62 | + | {URL: "https://older.example", Title: "Older day", LastVisited: time.Date(2026, 4, 22, 8, 0, 0, 0, loc)}, | |
| 63 | + | }, | |
| 64 | + | AppendSkipFirstDate: "2026-04-23", | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | html := historyTimelineAppendHTML(data) | |
| 68 | + | ||
| 69 | + | assert.NotContains(t, html, ">Thu Apr 23</h3>") | |
| 70 | + | assert.NotContains(t, html, `<section class="sv-timeline-group"><ul class="sv-list">`) | |
| 71 | + | assert.Contains(t, html, `data-sv-history-merge-date="2026-04-23"`) | |
| 72 | + | assert.Contains(t, html, "Same day") | |
| 73 | + | assert.Contains(t, html, `data-sv-history-date="2026-04-22"`) | |
| 74 | + | assert.Contains(t, html, ">Wed Apr 22</h3>") | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func TestHistoryItemURLTruncatesLongURLs(t *testing.T) { | |
| 78 | + | t.Parallel() | |
| 79 | + | ||
| 80 | + | longURL := "https://example.com/" + strings.Repeat("a", historyURLDisplayMaxRunes) | |
| 81 | + | entry := &entity.HistoryEntry{URL: longURL} | |
| 82 | + | ||
| 83 | + | displayURL := historyItemURL(entry) | |
| 84 | + | ||
| 85 | + | assert.LessOrEqual(t, len([]rune(displayURL)), historyURLDisplayMaxRunes) | |
| 86 | + | assert.Contains(t, displayURL, "…") | |
| 87 | + | assert.NotContains(t, displayURL, strings.Repeat("a", historyURLDisplayMaxRunes)) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func TestHistoryItemFaviconURLUsesLocalCachedFaviconAPI(t *testing.T) { | |
| 91 | + | t.Parallel() | |
| 92 | + | ||
| 93 | + | entry := &entity.HistoryEntry{URL: "https://example.com/docs?token=secret#section"} | |
| 94 | + | ||
| 95 | + | assert.Equal(t, "/api/favicon?domain=example.com&size=32", historyItemFaviconURL(entry)) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func TestHistoryItemFaviconURLKeepsPortsForCacheKeys(t *testing.T) { | |
| 99 | + | t.Parallel() | |
| 100 | + | ||
| 101 | + | entry := &entity.HistoryEntry{URL: "http://localhost:1455/success?token=secret"} | |
| 102 | + | ||
| 103 | + | assert.Equal(t, "/api/favicon?domain=localhost%3A1455&size=32", historyItemFaviconURL(entry)) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestHistoryItemFaviconURLIgnoresStoredRemoteFaviconURL(t *testing.T) { | |
| 107 | + | t.Parallel() | |
| 108 | + | ||
| 109 | + | entry := &entity.HistoryEntry{ | |
| 110 | + | URL: "https://example.com/docs", | |
| 111 | + | FaviconURL: "https://tracker.example/favicon.png", | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | assert.Equal(t, "/api/favicon?domain=example.com&size=32", historyItemFaviconURL(entry)) | |
| 115 | + | } | |
A internal/ui/systemviews/icons.go +26 -0
| @@ -0,0 +1,26 @@ | |||
| 1 | + | package systemviews | |
| 2 | + | ||
| 3 | + | type systemIcon string | |
| 4 | + | ||
| 5 | + | const ( | |
| 6 | + | iconTrash systemIcon = "trash-2" | |
| 7 | + | iconX systemIcon = "x" | |
| 8 | + | iconSearch systemIcon = "search" | |
| 9 | + | iconSave systemIcon = "save" | |
| 10 | + | iconPlus systemIcon = "plus" | |
| 11 | + | iconExternal systemIcon = "external-link" | |
| 12 | + | iconRotateCCW systemIcon = "rotate-ccw" | |
| 13 | + | iconCheck systemIcon = "check" | |
| 14 | + | iconHistory systemIcon = "history" | |
| 15 | + | iconFolder systemIcon = "folder" | |
| 16 | + | iconTag systemIcon = "tag" | |
| 17 | + | iconSettings systemIcon = "settings" | |
| 18 | + | iconAlert systemIcon = "alert-triangle" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | func iconClass(name systemIcon) string { | |
| 22 | + | if name == "" { | |
| 23 | + | return "sv-icon" | |
| 24 | + | } | |
| 25 | + | return "sv-icon sv-icon-" + string(name) | |
| 26 | + | } | |
A internal/ui/systemviews/icons.templ +65 -0
| @@ -0,0 +1,65 @@ | |||
| 1 | + | package systemviews | |
| 2 | + | ||
| 3 | + | templ Icon(name systemIcon) { | |
| 4 | + | <svg class={ iconClass(name) } xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"> | |
| 5 | + | if name == iconTrash { | |
| 6 | + | <path d="M10 11v6"></path> | |
| 7 | + | <path d="M14 11v6"></path> | |
| 8 | + | <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path> | |
| 9 | + | <path d="M3 6h18"></path> | |
| 10 | + | <path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path> | |
| 11 | + | } else if name == iconX { | |
| 12 | + | <path d="M18 6 6 18"></path> | |
| 13 | + | <path d="m6 6 12 12"></path> | |
| 14 | + | } else if name == iconSearch { | |
| 15 | + | <path d="m21 21-4.34-4.34"></path> | |
| 16 | + | <circle cx="11" cy="11" r="8"></circle> | |
| 17 | + | } else if name == iconSave { | |
| 18 | + | <path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"></path> | |
| 19 | + | <path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"></path> | |
| 20 | + | <path d="M7 3v4a1 1 0 0 0 1 1h7"></path> | |
| 21 | + | } else if name == iconPlus { | |
| 22 | + | <path d="M5 12h14"></path> | |
| 23 | + | <path d="M12 5v14"></path> | |
| 24 | + | } else if name == iconExternal { | |
| 25 | + | <path d="M15 3h6v6"></path> | |
| 26 | + | <path d="M10 14 21 3"></path> | |
| 27 | + | <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path> | |
| 28 | + | } else if name == iconRotateCCW { | |
| 29 | + | <path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path> | |
| 30 | + | <path d="M3 3v5h5"></path> | |
| 31 | + | } else if name == iconCheck { | |
| 32 | + | <path d="M20 6 9 17l-5-5"></path> | |
| 33 | + | } else if name == iconHistory { | |
| 34 | + | <path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path> | |
| 35 | + | <path d="M3 3v5h5"></path> | |
| 36 | + | <path d="M12 7v5l4 2"></path> | |
| 37 | + | } else if name == iconFolder { | |
| 38 | + | <path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"></path> | |
| 39 | + | } else if name == iconTag { | |
| 40 | + | <path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"></path> | |
| 41 | + | <circle cx="7.5" cy="7.5" r=".5" fill="currentColor"></circle> | |
| 42 | + | } else if name == iconSettings { | |
| 43 | + | <path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"></path> | |
| 44 | + | <circle cx="12" cy="12" r="3"></circle> | |
| 45 | + | } else if name == iconAlert { | |
| 46 | + | <path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"></path> | |
| 47 | + | <path d="M12 9v4"></path> | |
| 48 | + | <path d="M12 17h.01"></path> | |
| 49 | + | } else { | |
| 50 | + | <circle cx="12" cy="12" r="10"></circle> | |
| 51 | + | <path d="M9.09 9a3 3 0 1 1 5.83 1c0 2-3 2-3 4"></path> | |
| 52 | + | <path d="M12 17h.01"></path> | |
| 53 | + | } | |
| 54 | + | </svg> | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | templ ButtonIconLabel(name systemIcon, label string) { | |
| 58 | + | @Icon(name) | |
| 59 | + | <span>{ label }</span> | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | templ IconOnly(name systemIcon, label string) { | |
| 63 | + | @Icon(name) | |
| 64 | + | <span class="sv-sr-only">{ label }</span> | |
| 65 | + | } | |