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
| @@ -291,7 +291,23 @@ func AppearanceForm(config dto.SystemviewConfigPayload) templ.Component { | |||
| 291 | 291 | if templ_7745c5c3_Err != nil { | |
| 292 | 292 | return templ_7745c5c3_Err | |
| 293 | 293 | } | |
| 294 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<div class=\"sv-button-row sv-form-actions\"><button class=\"sv-button\" type=\"submit\">Save appearance</button> <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></div></form>") | |
| 294 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<div class=\"sv-button-row sv-form-actions\"><button class=\"sv-button\" type=\"submit\">") | |
| 295 | + | if templ_7745c5c3_Err != nil { | |
| 296 | + | return templ_7745c5c3_Err | |
| 297 | + | } | |
| 298 | + | templ_7745c5c3_Err = ButtonIconLabel(iconSave, "Save appearance").Render(ctx, templ_7745c5c3_Buffer) | |
| 299 | + | if templ_7745c5c3_Err != nil { | |
| 300 | + | return templ_7745c5c3_Err | |
| 301 | + | } | |
| 302 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</button> <button class=\"sv-button sv-button-secondary\" type=\"button\" data-sv-action=\"config.appearance.reset\" data-sv-confirm=\"Reset appearance settings to defaults?\">") | |
| 303 | + | if templ_7745c5c3_Err != nil { | |
| 304 | + | return templ_7745c5c3_Err | |
| 305 | + | } | |
| 306 | + | templ_7745c5c3_Err = ButtonIconLabel(iconRotateCCW, "Reset appearance").Render(ctx, templ_7745c5c3_Buffer) | |
| 307 | + | if templ_7745c5c3_Err != nil { | |
| 308 | + | return templ_7745c5c3_Err | |
| 309 | + | } | |
| 310 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</button></div></form>") | |
| 295 | 311 | if templ_7745c5c3_Err != nil { | |
| 296 | 312 | return templ_7745c5c3_Err | |
| 297 | 313 | } | |
| @@ -320,7 +336,7 @@ func PaletteFields(title string, prefix string, palette dto.ColorPalette) templ. | |||
| 320 | 336 | templ_7745c5c3_Var13 = templ.NopComponent | |
| 321 | 337 | } | |
| 322 | 338 | ctx = templ.ClearChildren(ctx) | |
| 323 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<fieldset class=\"sv-fieldset\"><legend>") | |
| 339 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<fieldset class=\"sv-fieldset\"><legend>") | |
| 324 | 340 | if templ_7745c5c3_Err != nil { | |
| 325 | 341 | return templ_7745c5c3_Err | |
| 326 | 342 | } | |
| @@ -333,7 +349,7 @@ func PaletteFields(title string, prefix string, palette dto.ColorPalette) templ. | |||
| 333 | 349 | if templ_7745c5c3_Err != nil { | |
| 334 | 350 | return templ_7745c5c3_Err | |
| 335 | 351 | } | |
| 336 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</legend> <label><span>Background</span><input name=\"") | |
| 352 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</legend> <label><span>Background</span><input name=\"") | |
| 337 | 353 | if templ_7745c5c3_Err != nil { | |
| 338 | 354 | return templ_7745c5c3_Err | |
| 339 | 355 | } | |
| @@ -346,7 +362,7 @@ func PaletteFields(title string, prefix string, palette dto.ColorPalette) templ. | |||
| 346 | 362 | if templ_7745c5c3_Err != nil { | |
| 347 | 363 | return templ_7745c5c3_Err | |
| 348 | 364 | } | |
| 349 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" type=\"text\" value=\"") | |
| 365 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" type=\"text\" value=\"") | |
| 350 | 366 | if templ_7745c5c3_Err != nil { | |
| 351 | 367 | return templ_7745c5c3_Err | |
| 352 | 368 | } | |
| @@ -359,7 +375,7 @@ func PaletteFields(title string, prefix string, palette dto.ColorPalette) templ. | |||
| 359 | 375 | if templ_7745c5c3_Err != nil { | |
| 360 | 376 | return templ_7745c5c3_Err | |
| 361 | 377 | } | |
| 362 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" required></label> <label><span>Surface</span><input name=\"") | |
| 378 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" required></label> <label><span>Surface</span><input name=\"") | |
| 363 | 379 | if templ_7745c5c3_Err != nil { | |
| 364 | 380 | return templ_7745c5c3_Err | |
| 365 | 381 | } | |
| @@ -372,7 +388,7 @@ func PaletteFields(title string, prefix string, palette dto.ColorPalette) templ. | |||
| 372 | 388 | if templ_7745c5c3_Err != nil { | |
| 373 | 389 | return templ_7745c5c3_Err | |
| 374 | 390 | } | |
| 375 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" type=\"text\" value=\"") | |
| 391 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" type=\"text\" value=\"") | |
| 376 | 392 | if templ_7745c5c3_Err != nil { | |
| 377 | 393 | return templ_7745c5c3_Err | |
| 378 | 394 | } | |
| @@ -385,7 +401,7 @@ func PaletteFields(title string, prefix string, palette dto.ColorPalette) templ. | |||
| 385 | 401 | if templ_7745c5c3_Err != nil { | |
| 386 | 402 | return templ_7745c5c3_Err | |
| 387 | 403 | } | |
| 388 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" required></label> <label><span>Surface variant</span><input name=\"") | |
| 404 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" required></label> <label><span>Surface variant</span><input name=\"") | |
| 389 | 405 | if templ_7745c5c3_Err != nil { | |
| 390 | 406 | return templ_7745c5c3_Err | |
| 391 | 407 | } | |
| @@ -398,7 +414,7 @@ func PaletteFields(title string, prefix string, palette dto.ColorPalette) templ. | |||
| 398 | 414 | if templ_7745c5c3_Err != nil { | |
| 399 | 415 | return templ_7745c5c3_Err | |
| 400 | 416 | } | |
| 401 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" type=\"text\" value=\"") | |
| 417 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" type=\"text\" value=\"") | |
| 402 | 418 | if templ_7745c5c3_Err != nil { | |
| 403 | 419 | return templ_7745c5c3_Err | |
| 404 | 420 | } | |
| @@ -411,7 +427,7 @@ func PaletteFields(title string, prefix string, palette dto.ColorPalette) templ. | |||
| 411 | 427 | if templ_7745c5c3_Err != nil { | |
| 412 | 428 | return templ_7745c5c3_Err | |
| 413 | 429 | } | |
| 414 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" required></label> <label><span>Text</span><input name=\"") | |
| 430 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" required></label> <label><span>Text</span><input name=\"") | |
| 415 | 431 | if templ_7745c5c3_Err != nil { | |
| 416 | 432 | return templ_7745c5c3_Err | |
| 417 | 433 | } | |
| @@ -424,7 +440,7 @@ func PaletteFields(title string, prefix string, palette dto.ColorPalette) templ. | |||
| 424 | 440 | if templ_7745c5c3_Err != nil { | |
| 425 | 441 | return templ_7745c5c3_Err | |
| 426 | 442 | } | |
| 427 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" type=\"text\" value=\"") | |
| 443 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" type=\"text\" value=\"") | |
| 428 | 444 | if templ_7745c5c3_Err != nil { | |
| 429 | 445 | return templ_7745c5c3_Err | |
| 430 | 446 | } | |
| @@ -437,7 +453,7 @@ func PaletteFields(title string, prefix string, palette dto.ColorPalette) templ. | |||
| 437 | 453 | if templ_7745c5c3_Err != nil { | |
| 438 | 454 | return templ_7745c5c3_Err | |
| 439 | 455 | } | |
| 440 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" required></label> <label><span>Muted</span><input name=\"") | |
| 456 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" required></label> <label><span>Muted</span><input name=\"") | |
| 441 | 457 | if templ_7745c5c3_Err != nil { | |
| 442 | 458 | return templ_7745c5c3_Err | |
| 443 | 459 | } | |
| @@ -450,7 +466,7 @@ func PaletteFields(title string, prefix string, palette dto.ColorPalette) templ. | |||
| 450 | 466 | if templ_7745c5c3_Err != nil { | |
| 451 | 467 | return templ_7745c5c3_Err | |
| 452 | 468 | } | |
| 453 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" type=\"text\" value=\"") | |
| 469 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" type=\"text\" value=\"") | |
| 454 | 470 | if templ_7745c5c3_Err != nil { | |
| 455 | 471 | return templ_7745c5c3_Err | |
| 456 | 472 | } | |
| @@ -463,7 +479,7 @@ func PaletteFields(title string, prefix string, palette dto.ColorPalette) templ. | |||
| 463 | 479 | if templ_7745c5c3_Err != nil { | |
| 464 | 480 | return templ_7745c5c3_Err | |
| 465 | 481 | } | |
| 466 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" required></label> <label><span>Accent</span><input name=\"") | |
| 482 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" required></label> <label><span>Accent</span><input name=\"") | |
| 467 | 483 | if templ_7745c5c3_Err != nil { | |
| 468 | 484 | return templ_7745c5c3_Err | |
| 469 | 485 | } | |
| @@ -476,7 +492,7 @@ func PaletteFields(title string, prefix string, palette dto.ColorPalette) templ. | |||
| 476 | 492 | if templ_7745c5c3_Err != nil { | |
| 477 | 493 | return templ_7745c5c3_Err | |
| 478 | 494 | } | |
| 479 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" type=\"text\" value=\"") | |
| 495 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" type=\"text\" value=\"") | |
| 480 | 496 | if templ_7745c5c3_Err != nil { | |
| 481 | 497 | return templ_7745c5c3_Err | |
| 482 | 498 | } | |
| @@ -489,7 +505,7 @@ func PaletteFields(title string, prefix string, palette dto.ColorPalette) templ. | |||
| 489 | 505 | if templ_7745c5c3_Err != nil { | |
| 490 | 506 | return templ_7745c5c3_Err | |
| 491 | 507 | } | |
| 492 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" required></label> <label><span>Border</span><input name=\"") | |
| 508 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" required></label> <label><span>Border</span><input name=\"") | |
| 493 | 509 | if templ_7745c5c3_Err != nil { | |
| 494 | 510 | return templ_7745c5c3_Err | |
| 495 | 511 | } | |
| @@ -502,7 +518,7 @@ func PaletteFields(title string, prefix string, palette dto.ColorPalette) templ. | |||
| 502 | 518 | if templ_7745c5c3_Err != nil { | |
| 503 | 519 | return templ_7745c5c3_Err | |
| 504 | 520 | } | |
| 505 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" type=\"text\" value=\"") | |
| 521 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" type=\"text\" value=\"") | |
| 506 | 522 | if templ_7745c5c3_Err != nil { | |
| 507 | 523 | return templ_7745c5c3_Err | |
| 508 | 524 | } | |
| @@ -515,7 +531,7 @@ func PaletteFields(title string, prefix string, palette dto.ColorPalette) templ. | |||
| 515 | 531 | if templ_7745c5c3_Err != nil { | |
| 516 | 532 | return templ_7745c5c3_Err | |
| 517 | 533 | } | |
| 518 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" required></label></fieldset>") | |
| 534 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\" required></label></fieldset>") | |
| 519 | 535 | if templ_7745c5c3_Err != nil { | |
| 520 | 536 | return templ_7745c5c3_Err | |
| 521 | 537 | } | |
| @@ -544,7 +560,7 @@ func SearchSettings(config dto.SystemviewConfigPayload) templ.Component { | |||
| 544 | 560 | templ_7745c5c3_Var29 = templ.NopComponent | |
| 545 | 561 | } | |
| 546 | 562 | ctx = templ.ClearChildren(ctx) | |
| 547 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<form class=\"sv-card-form\" data-sv-action=\"config.search.save\"><label><span>Default search engine</span><input name=\"default_search_engine\" type=\"text\" value=\"") | |
| 563 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<form class=\"sv-card-form\" data-sv-action=\"config.search.save\"><label><span>Default search engine</span><input name=\"default_search_engine\" type=\"text\" value=\"") | |
| 548 | 564 | if templ_7745c5c3_Err != nil { | |
| 549 | 565 | return templ_7745c5c3_Err | |
| 550 | 566 | } | |
| @@ -557,7 +573,23 @@ func SearchSettings(config dto.SystemviewConfigPayload) templ.Component { | |||
| 557 | 573 | if templ_7745c5c3_Err != nil { | |
| 558 | 574 | return templ_7745c5c3_Err | |
| 559 | 575 | } | |
| 560 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\" placeholder=\"https://duckduckgo.com/?q=%s\" required></label> <button class=\"sv-button\" type=\"submit\">Save search default</button></form><div class=\"sv-group\"><div class=\"sv-group-header\"><h4>Search shortcuts</h4></div><p class=\"sv-meta\">Shortcut URLs must include the %s placeholder for the search query.</p><form class=\"sv-inline-form\" data-sv-action=\"config.searchShortcut.create\"><label><span>Key</span><input name=\"key\" type=\"text\" placeholder=\"ddg\" required></label> <label><span>URL</span><input name=\"url\" type=\"text\" placeholder=\"https://duckduckgo.com/?q=%s\" pattern=\".*%s.*\" required></label> <label><span>Description</span><input name=\"description\" type=\"text\" placeholder=\"DuckDuckGo\"></label> <button class=\"sv-button sv-button-secondary\" type=\"submit\">Add shortcut</button></form>") | |
| 576 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" placeholder=\"https://duckduckgo.com/?q=%s\" required></label> <button class=\"sv-button\" type=\"submit\">") | |
| 577 | + | if templ_7745c5c3_Err != nil { | |
| 578 | + | return templ_7745c5c3_Err | |
| 579 | + | } | |
| 580 | + | templ_7745c5c3_Err = ButtonIconLabel(iconSave, "Save search default").Render(ctx, templ_7745c5c3_Buffer) | |
| 581 | + | if templ_7745c5c3_Err != nil { | |
| 582 | + | return templ_7745c5c3_Err | |
| 583 | + | } | |
| 584 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</button></form><div class=\"sv-group\"><div class=\"sv-group-header\"><h4>Search shortcuts</h4></div><p class=\"sv-meta\">Shortcut URLs must include the %s placeholder for the search query.</p><form class=\"sv-inline-form\" data-sv-action=\"config.searchShortcut.create\"><label><span>Key</span><input name=\"key\" type=\"text\" placeholder=\"ddg\" required></label> <label><span>URL</span><input name=\"url\" type=\"text\" placeholder=\"https://duckduckgo.com/?q=%s\" pattern=\".*%s.*\" required></label> <label><span>Description</span><input name=\"description\" type=\"text\" placeholder=\"DuckDuckGo\"></label> <button class=\"sv-button sv-button-secondary\" type=\"submit\">") | |
| 585 | + | if templ_7745c5c3_Err != nil { | |
| 586 | + | return templ_7745c5c3_Err | |
| 587 | + | } | |
| 588 | + | templ_7745c5c3_Err = ButtonIconLabel(iconPlus, "Add shortcut").Render(ctx, templ_7745c5c3_Buffer) | |
| 589 | + | if templ_7745c5c3_Err != nil { | |
| 590 | + | return templ_7745c5c3_Err | |
| 591 | + | } | |
| 592 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</button></form>") | |
| 561 | 593 | if templ_7745c5c3_Err != nil { | |
| 562 | 594 | return templ_7745c5c3_Err | |
| 563 | 595 | } | |
| @@ -568,7 +600,7 @@ func SearchSettings(config dto.SystemviewConfigPayload) templ.Component { | |||
| 568 | 600 | return templ_7745c5c3_Err | |
| 569 | 601 | } | |
| 570 | 602 | } else { | |
| 571 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<ul class=\"sv-list\">") | |
| 603 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<ul class=\"sv-list\">") | |
| 572 | 604 | if templ_7745c5c3_Err != nil { | |
| 573 | 605 | return templ_7745c5c3_Err | |
| 574 | 606 | } | |
| @@ -578,12 +610,12 @@ func SearchSettings(config dto.SystemviewConfigPayload) templ.Component { | |||
| 578 | 610 | return templ_7745c5c3_Err | |
| 579 | 611 | } | |
| 580 | 612 | } | |
| 581 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</ul>") | |
| 613 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</ul>") | |
| 582 | 614 | if templ_7745c5c3_Err != nil { | |
| 583 | 615 | return templ_7745c5c3_Err | |
| 584 | 616 | } | |
| 585 | 617 | } | |
| 586 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</div>") | |
| 618 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</div>") | |
| 587 | 619 | if templ_7745c5c3_Err != nil { | |
| 588 | 620 | return templ_7745c5c3_Err | |
| 589 | 621 | } | |
| @@ -612,7 +644,7 @@ func SearchShortcutItem(shortcut searchShortcutRow) templ.Component { | |||
| 612 | 644 | templ_7745c5c3_Var31 = templ.NopComponent | |
| 613 | 645 | } | |
| 614 | 646 | ctx = templ.ClearChildren(ctx) | |
| 615 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<li class=\"sv-list-row\"><form class=\"sv-inline-form\" data-sv-action=\"config.searchShortcut.update\"><input type=\"hidden\" name=\"key\" value=\"") | |
| 647 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<li class=\"sv-list-row\"><form class=\"sv-inline-form\" data-sv-action=\"config.searchShortcut.update\"><input type=\"hidden\" name=\"key\" value=\"") | |
| 616 | 648 | if templ_7745c5c3_Err != nil { | |
| 617 | 649 | return templ_7745c5c3_Err | |
| 618 | 650 | } | |
| @@ -625,7 +657,7 @@ func SearchShortcutItem(shortcut searchShortcutRow) templ.Component { | |||
| 625 | 657 | if templ_7745c5c3_Err != nil { | |
| 626 | 658 | return templ_7745c5c3_Err | |
| 627 | 659 | } | |
| 628 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\"> <label><span>Key</span><input name=\"new_key\" type=\"text\" value=\"") | |
| 660 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\"> <label><span>Key</span><input name=\"new_key\" type=\"text\" value=\"") | |
| 629 | 661 | if templ_7745c5c3_Err != nil { | |
| 630 | 662 | return templ_7745c5c3_Err | |
| 631 | 663 | } | |
| @@ -638,7 +670,7 @@ func SearchShortcutItem(shortcut searchShortcutRow) templ.Component { | |||
| 638 | 670 | if templ_7745c5c3_Err != nil { | |
| 639 | 671 | return templ_7745c5c3_Err | |
| 640 | 672 | } | |
| 641 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\" required></label> <label><span>URL</span><input name=\"url\" type=\"text\" value=\"") | |
| 673 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\" required></label> <label><span>URL</span><input name=\"url\" type=\"text\" value=\"") | |
| 642 | 674 | if templ_7745c5c3_Err != nil { | |
| 643 | 675 | return templ_7745c5c3_Err | |
| 644 | 676 | } | |
| @@ -651,7 +683,7 @@ func SearchShortcutItem(shortcut searchShortcutRow) templ.Component { | |||
| 651 | 683 | if templ_7745c5c3_Err != nil { | |
| 652 | 684 | return templ_7745c5c3_Err | |
| 653 | 685 | } | |
| 654 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" pattern=\".*%s.*\" required></label> <label><span>Description</span><input name=\"description\" type=\"text\" value=\"") | |
| 686 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\" pattern=\".*%s.*\" required></label> <label><span>Description</span><input name=\"description\" type=\"text\" value=\"") | |
| 655 | 687 | if templ_7745c5c3_Err != nil { | |
| 656 | 688 | return templ_7745c5c3_Err | |
| 657 | 689 | } | |
| @@ -664,20 +696,49 @@ func SearchShortcutItem(shortcut searchShortcutRow) templ.Component { | |||
| 664 | 696 | if templ_7745c5c3_Err != nil { | |
| 665 | 697 | return templ_7745c5c3_Err | |
| 666 | 698 | } | |
| 667 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\"></label> <button class=\"sv-button sv-button-secondary\" type=\"submit\">Save</button> <button class=\"sv-button sv-button-secondary sv-button-danger\" type=\"button\" data-sv-action=\"config.searchShortcut.delete\" data-key=\"") | |
| 699 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\"></label> <button class=\"sv-button sv-button-secondary\" type=\"submit\">") | |
| 700 | + | if templ_7745c5c3_Err != nil { | |
| 701 | + | return templ_7745c5c3_Err | |
| 702 | + | } | |
| 703 | + | templ_7745c5c3_Err = ButtonIconLabel(iconSave, "Save").Render(ctx, templ_7745c5c3_Buffer) | |
| 704 | + | if templ_7745c5c3_Err != nil { | |
| 705 | + | return templ_7745c5c3_Err | |
| 706 | + | } | |
| 707 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</button> <button class=\"sv-button sv-button-secondary sv-button-danger sv-button-icon-only\" type=\"button\" data-sv-action=\"config.searchShortcut.delete\" data-key=\"") | |
| 668 | 708 | if templ_7745c5c3_Err != nil { | |
| 669 | 709 | return templ_7745c5c3_Err | |
| 670 | 710 | } | |
| 671 | 711 | var templ_7745c5c3_Var36 string | |
| 672 | 712 | templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(shortcut.Key) | |
| 673 | 713 | if templ_7745c5c3_Err != nil { | |
| 674 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 98, Col: 149} | |
| 714 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 98, Col: 169} | |
| 675 | 715 | } | |
| 676 | 716 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) | |
| 677 | 717 | if templ_7745c5c3_Err != nil { | |
| 678 | 718 | return templ_7745c5c3_Err | |
| 679 | 719 | } | |
| 680 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\" data-sv-confirm=\"Delete this search shortcut?\">Delete</button></form></li>") | |
| 720 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" data-sv-confirm=\"Delete this search shortcut?\" aria-label=\"") | |
| 721 | + | if templ_7745c5c3_Err != nil { | |
| 722 | + | return templ_7745c5c3_Err | |
| 723 | + | } | |
| 724 | + | var templ_7745c5c3_Var37 string | |
| 725 | + | templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs("Delete search shortcut " + shortcut.Key) | |
| 726 | + | if templ_7745c5c3_Err != nil { | |
| 727 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 98, Col: 272} | |
| 728 | + | } | |
| 729 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) | |
| 730 | + | if templ_7745c5c3_Err != nil { | |
| 731 | + | return templ_7745c5c3_Err | |
| 732 | + | } | |
| 733 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\">") | |
| 734 | + | if templ_7745c5c3_Err != nil { | |
| 735 | + | return templ_7745c5c3_Err | |
| 736 | + | } | |
| 737 | + | templ_7745c5c3_Err = IconOnly(iconTrash, "Delete search shortcut "+shortcut.Key).Render(ctx, templ_7745c5c3_Buffer) | |
| 738 | + | if templ_7745c5c3_Err != nil { | |
| 739 | + | return templ_7745c5c3_Err | |
| 740 | + | } | |
| 741 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</button></form></li>") | |
| 681 | 742 | if templ_7745c5c3_Err != nil { | |
| 682 | 743 | return templ_7745c5c3_Err | |
| 683 | 744 | } | |
| @@ -701,230 +762,238 @@ func PerformanceSettings(config dto.SystemviewConfigPayload) templ.Component { | |||
| 701 | 762 | }() | |
| 702 | 763 | } | |
| 703 | 764 | ctx = templ.InitializeContext(ctx) | |
| 704 | - | templ_7745c5c3_Var37 := templ.GetChildren(ctx) | |
| 705 | - | if templ_7745c5c3_Var37 == nil { | |
| 706 | - | templ_7745c5c3_Var37 = templ.NopComponent | |
| 765 | + | templ_7745c5c3_Var38 := templ.GetChildren(ctx) | |
| 766 | + | if templ_7745c5c3_Var38 == nil { | |
| 767 | + | templ_7745c5c3_Var38 = templ.NopComponent | |
| 707 | 768 | } | |
| 708 | 769 | ctx = templ.ClearChildren(ctx) | |
| 709 | 770 | customProfile := performanceProfileSelected(config.Performance.Profile, "custom") | |
| 710 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<form class=\"sv-card-form sv-config-form\" data-sv-action=\"config.performance.save\"><label><span>Profile</span><select name=\"profile\"><option value=\"default\"") | |
| 771 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<form class=\"sv-card-form sv-config-form\" data-sv-action=\"config.performance.save\"><label><span>Profile</span><select name=\"profile\"><option value=\"default\"") | |
| 711 | 772 | if templ_7745c5c3_Err != nil { | |
| 712 | 773 | return templ_7745c5c3_Err | |
| 713 | 774 | } | |
| 714 | 775 | if performanceProfileSelected(config.Performance.Profile, "default") { | |
| 715 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, " selected") | |
| 776 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " selected") | |
| 716 | 777 | if templ_7745c5c3_Err != nil { | |
| 717 | 778 | return templ_7745c5c3_Err | |
| 718 | 779 | } | |
| 719 | 780 | } | |
| 720 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, ">Default</option> <option value=\"lite\"") | |
| 781 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, ">Default</option> <option value=\"lite\"") | |
| 721 | 782 | if templ_7745c5c3_Err != nil { | |
| 722 | 783 | return templ_7745c5c3_Err | |
| 723 | 784 | } | |
| 724 | 785 | if performanceProfileSelected(config.Performance.Profile, "lite") { | |
| 725 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, " selected") | |
| 786 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " selected") | |
| 726 | 787 | if templ_7745c5c3_Err != nil { | |
| 727 | 788 | return templ_7745c5c3_Err | |
| 728 | 789 | } | |
| 729 | 790 | } | |
| 730 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, ">Lite</option> <option value=\"max\"") | |
| 791 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, ">Lite</option> <option value=\"max\"") | |
| 731 | 792 | if templ_7745c5c3_Err != nil { | |
| 732 | 793 | return templ_7745c5c3_Err | |
| 733 | 794 | } | |
| 734 | 795 | if performanceProfileSelected(config.Performance.Profile, "max") { | |
| 735 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, " selected") | |
| 796 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " selected") | |
| 736 | 797 | if templ_7745c5c3_Err != nil { | |
| 737 | 798 | return templ_7745c5c3_Err | |
| 738 | 799 | } | |
| 739 | 800 | } | |
| 740 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, ">Max</option> <option value=\"custom\"") | |
| 801 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, ">Max</option> <option value=\"custom\"") | |
| 741 | 802 | if templ_7745c5c3_Err != nil { | |
| 742 | 803 | return templ_7745c5c3_Err | |
| 743 | 804 | } | |
| 744 | 805 | if performanceProfileSelected(config.Performance.Profile, "custom") { | |
| 745 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, " selected") | |
| 806 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " selected") | |
| 746 | 807 | if templ_7745c5c3_Err != nil { | |
| 747 | 808 | return templ_7745c5c3_Err | |
| 748 | 809 | } | |
| 749 | 810 | } | |
| 750 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, ">Custom</option></select></label><p class=\"sv-meta\">Choose Custom to edit individual performance values.</p><label><span>Skia CPU threads</span><input name=\"skia_cpu_threads\" type=\"number\" min=\"0\" max=\"8\" value=\"") | |
| 811 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, ">Custom</option></select></label><p class=\"sv-meta\">Choose Custom to edit individual performance values.</p><label><span>Skia CPU threads</span><input name=\"skia_cpu_threads\" type=\"number\" min=\"0\" max=\"8\" value=\"") | |
| 751 | 812 | if templ_7745c5c3_Err != nil { | |
| 752 | 813 | return templ_7745c5c3_Err | |
| 753 | 814 | } | |
| 754 | - | var templ_7745c5c3_Var38 string | |
| 755 | - | templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Custom.SkiaCPUThreads)) | |
| 815 | + | var templ_7745c5c3_Var39 string | |
| 816 | + | templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Custom.SkiaCPUThreads)) | |
| 756 | 817 | if templ_7745c5c3_Err != nil { | |
| 757 | 818 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 113, Col: 164} | |
| 758 | 819 | } | |
| 759 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) | |
| 820 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) | |
| 760 | 821 | if templ_7745c5c3_Err != nil { | |
| 761 | 822 | return templ_7745c5c3_Err | |
| 762 | 823 | } | |
| 763 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\"") | |
| 824 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\"") | |
| 764 | 825 | if templ_7745c5c3_Err != nil { | |
| 765 | 826 | return templ_7745c5c3_Err | |
| 766 | 827 | } | |
| 767 | 828 | if !customProfile { | |
| 768 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " disabled") | |
| 829 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, " disabled") | |
| 769 | 830 | if templ_7745c5c3_Err != nil { | |
| 770 | 831 | return templ_7745c5c3_Err | |
| 771 | 832 | } | |
| 772 | 833 | } | |
| 773 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "></label> <label><span>Skia GPU threads</span><input name=\"skia_gpu_threads\" type=\"number\" min=\"-1\" max=\"8\" value=\"") | |
| 834 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "></label> <label><span>Skia GPU threads</span><input name=\"skia_gpu_threads\" type=\"number\" min=\"-1\" max=\"8\" value=\"") | |
| 774 | 835 | if templ_7745c5c3_Err != nil { | |
| 775 | 836 | return templ_7745c5c3_Err | |
| 776 | 837 | } | |
| 777 | - | var templ_7745c5c3_Var39 string | |
| 778 | - | templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Custom.SkiaGPUThreads)) | |
| 838 | + | var templ_7745c5c3_Var40 string | |
| 839 | + | templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Custom.SkiaGPUThreads)) | |
| 779 | 840 | if templ_7745c5c3_Err != nil { | |
| 780 | 841 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 114, Col: 165} | |
| 781 | 842 | } | |
| 782 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) | |
| 843 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) | |
| 783 | 844 | if templ_7745c5c3_Err != nil { | |
| 784 | 845 | return templ_7745c5c3_Err | |
| 785 | 846 | } | |
| 786 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\"") | |
| 847 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\"") | |
| 787 | 848 | if templ_7745c5c3_Err != nil { | |
| 788 | 849 | return templ_7745c5c3_Err | |
| 789 | 850 | } | |
| 790 | 851 | if !customProfile { | |
| 791 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, " disabled") | |
| 852 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, " disabled") | |
| 792 | 853 | if templ_7745c5c3_Err != nil { | |
| 793 | 854 | return templ_7745c5c3_Err | |
| 794 | 855 | } | |
| 795 | 856 | } | |
| 796 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "></label> <label><span>Web process memory MB</span><input name=\"web_process_memory_mb\" type=\"number\" min=\"0\" max=\"16384\" value=\"") | |
| 857 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "></label> <label><span>Web process memory MB</span><input name=\"web_process_memory_mb\" type=\"number\" min=\"0\" max=\"16384\" value=\"") | |
| 797 | 858 | if templ_7745c5c3_Err != nil { | |
| 798 | 859 | return templ_7745c5c3_Err | |
| 799 | 860 | } | |
| 800 | - | var templ_7745c5c3_Var40 string | |
| 801 | - | templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Custom.WebProcessMemoryMB)) | |
| 861 | + | var templ_7745c5c3_Var41 string | |
| 862 | + | templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Custom.WebProcessMemoryMB)) | |
| 802 | 863 | if templ_7745c5c3_Err != nil { | |
| 803 | 864 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 115, Col: 182} | |
| 804 | 865 | } | |
| 805 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) | |
| 866 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) | |
| 806 | 867 | if templ_7745c5c3_Err != nil { | |
| 807 | 868 | return templ_7745c5c3_Err | |
| 808 | 869 | } | |
| 809 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\"") | |
| 870 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\"") | |
| 810 | 871 | if templ_7745c5c3_Err != nil { | |
| 811 | 872 | return templ_7745c5c3_Err | |
| 812 | 873 | } | |
| 813 | 874 | if !customProfile { | |
| 814 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, " disabled") | |
| 875 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, " disabled") | |
| 815 | 876 | if templ_7745c5c3_Err != nil { | |
| 816 | 877 | return templ_7745c5c3_Err | |
| 817 | 878 | } | |
| 818 | 879 | } | |
| 819 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "></label> <label><span>Network process memory MB</span><input name=\"network_process_memory_mb\" type=\"number\" min=\"0\" max=\"4096\" value=\"") | |
| 880 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "></label> <label><span>Network process memory MB</span><input name=\"network_process_memory_mb\" type=\"number\" min=\"0\" max=\"4096\" value=\"") | |
| 820 | 881 | if templ_7745c5c3_Err != nil { | |
| 821 | 882 | return templ_7745c5c3_Err | |
| 822 | 883 | } | |
| 823 | - | var templ_7745c5c3_Var41 string | |
| 824 | - | templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Custom.NetworkProcessMemoryMB)) | |
| 884 | + | var templ_7745c5c3_Var42 string | |
| 885 | + | templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Custom.NetworkProcessMemoryMB)) | |
| 825 | 886 | if templ_7745c5c3_Err != nil { | |
| 826 | 887 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 116, Col: 193} | |
| 827 | 888 | } | |
| 828 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) | |
| 889 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) | |
| 829 | 890 | if templ_7745c5c3_Err != nil { | |
| 830 | 891 | return templ_7745c5c3_Err | |
| 831 | 892 | } | |
| 832 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\"") | |
| 893 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\"") | |
| 833 | 894 | if templ_7745c5c3_Err != nil { | |
| 834 | 895 | return templ_7745c5c3_Err | |
| 835 | 896 | } | |
| 836 | 897 | if !customProfile { | |
| 837 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, " disabled") | |
| 898 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " disabled") | |
| 838 | 899 | if templ_7745c5c3_Err != nil { | |
| 839 | 900 | return templ_7745c5c3_Err | |
| 840 | 901 | } | |
| 841 | 902 | } | |
| 842 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "></label> <label><span>WebView pool prewarm</span><input name=\"webview_pool_prewarm\" type=\"number\" min=\"0\" max=\"20\" value=\"") | |
| 903 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "></label> <label><span>WebView pool prewarm</span><input name=\"webview_pool_prewarm\" type=\"number\" min=\"0\" max=\"20\" value=\"") | |
| 843 | 904 | if templ_7745c5c3_Err != nil { | |
| 844 | 905 | return templ_7745c5c3_Err | |
| 845 | 906 | } | |
| 846 | - | var templ_7745c5c3_Var42 string | |
| 847 | - | templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Custom.WebViewPoolPrewarm)) | |
| 907 | + | var templ_7745c5c3_Var43 string | |
| 908 | + | templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Custom.WebViewPoolPrewarm)) | |
| 848 | 909 | if templ_7745c5c3_Err != nil { | |
| 849 | 910 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 117, Col: 177} | |
| 850 | 911 | } | |
| 851 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) | |
| 912 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) | |
| 852 | 913 | if templ_7745c5c3_Err != nil { | |
| 853 | 914 | return templ_7745c5c3_Err | |
| 854 | 915 | } | |
| 855 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\"") | |
| 916 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\"") | |
| 856 | 917 | if templ_7745c5c3_Err != nil { | |
| 857 | 918 | return templ_7745c5c3_Err | |
| 858 | 919 | } | |
| 859 | 920 | if !customProfile { | |
| 860 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " disabled") | |
| 921 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, " disabled") | |
| 861 | 922 | if templ_7745c5c3_Err != nil { | |
| 862 | 923 | return templ_7745c5c3_Err | |
| 863 | 924 | } | |
| 864 | 925 | } | |
| 865 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "></label> <button class=\"sv-button\" type=\"submit\">Save performance</button></form><div class=\"sv-stat-grid\"><div class=\"sv-stat-card\"><span class=\"sv-stat-value\">") | |
| 926 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "></label> <button class=\"sv-button\" type=\"submit\">") | |
| 866 | 927 | if templ_7745c5c3_Err != nil { | |
| 867 | 928 | return templ_7745c5c3_Err | |
| 868 | 929 | } | |
| 869 | - | var templ_7745c5c3_Var43 string | |
| 870 | - | templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Hardware.CPUThreads)) | |
| 871 | - | if templ_7745c5c3_Err != nil { | |
| 872 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 121, Col: 113} | |
| 873 | - | } | |
| 874 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) | |
| 930 | + | templ_7745c5c3_Err = ButtonIconLabel(iconSave, "Save performance").Render(ctx, templ_7745c5c3_Buffer) | |
| 875 | 931 | if templ_7745c5c3_Err != nil { | |
| 876 | 932 | return templ_7745c5c3_Err | |
| 877 | 933 | } | |
| 878 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</span><span class=\"sv-stat-label\">CPU threads</span></div><div class=\"sv-stat-card\"><span class=\"sv-stat-value\">") | |
| 934 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</button></form><div class=\"sv-stat-grid\"><div class=\"sv-stat-card\"><span class=\"sv-stat-value\">") | |
| 879 | 935 | if templ_7745c5c3_Err != nil { | |
| 880 | 936 | return templ_7745c5c3_Err | |
| 881 | 937 | } | |
| 882 | 938 | var templ_7745c5c3_Var44 string | |
| 883 | - | templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Hardware.TotalRAMMB)) | |
| 939 | + | templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Hardware.CPUThreads)) | |
| 884 | 940 | if templ_7745c5c3_Err != nil { | |
| 885 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 122, Col: 113} | |
| 941 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 121, Col: 113} | |
| 886 | 942 | } | |
| 887 | 943 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) | |
| 888 | 944 | if templ_7745c5c3_Err != nil { | |
| 889 | 945 | return templ_7745c5c3_Err | |
| 890 | 946 | } | |
| 891 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</span><span class=\"sv-stat-label\">RAM MB</span></div><div class=\"sv-stat-card\"><span class=\"sv-stat-value\">") | |
| 947 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</span><span class=\"sv-stat-label\">CPU threads</span></div><div class=\"sv-stat-card\"><span class=\"sv-stat-value\">") | |
| 892 | 948 | if templ_7745c5c3_Err != nil { | |
| 893 | 949 | return templ_7745c5c3_Err | |
| 894 | 950 | } | |
| 895 | 951 | var templ_7745c5c3_Var45 string | |
| 896 | - | templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(config.Performance.Hardware.GPUName) | |
| 952 | + | templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Hardware.TotalRAMMB)) | |
| 897 | 953 | if templ_7745c5c3_Err != nil { | |
| 898 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 123, Col: 93} | |
| 954 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 122, Col: 113} | |
| 899 | 955 | } | |
| 900 | 956 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) | |
| 901 | 957 | if templ_7745c5c3_Err != nil { | |
| 902 | 958 | return templ_7745c5c3_Err | |
| 903 | 959 | } | |
| 904 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</span><span class=\"sv-stat-label\">GPU</span></div><div class=\"sv-stat-card\"><span class=\"sv-stat-value\">") | |
| 960 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</span><span class=\"sv-stat-label\">RAM MB</span></div><div class=\"sv-stat-card\"><span class=\"sv-stat-value\">") | |
| 905 | 961 | if templ_7745c5c3_Err != nil { | |
| 906 | 962 | return templ_7745c5c3_Err | |
| 907 | 963 | } | |
| 908 | 964 | var templ_7745c5c3_Var46 string | |
| 909 | - | templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(config.EngineType) | |
| 965 | + | templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(config.Performance.Hardware.GPUName) | |
| 910 | 966 | if templ_7745c5c3_Err != nil { | |
| 911 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 124, Col: 75} | |
| 967 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 123, Col: 93} | |
| 912 | 968 | } | |
| 913 | 969 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) | |
| 914 | 970 | if templ_7745c5c3_Err != nil { | |
| 915 | 971 | return templ_7745c5c3_Err | |
| 916 | 972 | } | |
| 917 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</span><span class=\"sv-stat-label\">Engine</span></div></div>") | |
| 973 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</span><span class=\"sv-stat-label\">GPU</span></div><div class=\"sv-stat-card\"><span class=\"sv-stat-value\">") | |
| 974 | + | if templ_7745c5c3_Err != nil { | |
| 975 | + | return templ_7745c5c3_Err | |
| 976 | + | } | |
| 977 | + | var templ_7745c5c3_Var47 string | |
| 978 | + | templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(config.EngineType) | |
| 979 | + | if templ_7745c5c3_Err != nil { | |
| 980 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 124, Col: 75} | |
| 981 | + | } | |
| 982 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47)) | |
| 983 | + | if templ_7745c5c3_Err != nil { | |
| 984 | + | return templ_7745c5c3_Err | |
| 985 | + | } | |
| 986 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</span><span class=\"sv-stat-label\">Engine</span></div></div>") | |
| 918 | 987 | if templ_7745c5c3_Err != nil { | |
| 919 | 988 | return templ_7745c5c3_Err | |
| 920 | 989 | } | |
| 921 | 990 | if !engineIsWebKit(config.EngineType) { | |
| 922 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<p class=\"sv-meta\">Performance tuning applies only to the WebKit engine.</p>") | |
| 991 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<p class=\"sv-meta\">Performance tuning applies only to the WebKit engine.</p>") | |
| 923 | 992 | if templ_7745c5c3_Err != nil { | |
| 924 | 993 | return templ_7745c5c3_Err | |
| 925 | 994 | } | |
| 926 | 995 | } else { | |
| 927 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<p class=\"sv-meta\">Performance profile changes may require restarting the browser to affect existing engine processes.</p>") | |
| 996 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<p class=\"sv-meta\">Performance profile changes may require restarting the browser to affect existing engine processes.</p>") | |
| 928 | 997 | if templ_7745c5c3_Err != nil { | |
| 929 | 998 | return templ_7745c5c3_Err | |
| 930 | 999 | } | |
| @@ -949,9 +1018,9 @@ func ConfigKeybindings(keybindings port.KeybindingsConfig) templ.Component { | |||
| 949 | 1018 | }() | |
| 950 | 1019 | } | |
| 951 | 1020 | ctx = templ.InitializeContext(ctx) | |
| 952 | - | templ_7745c5c3_Var47 := templ.GetChildren(ctx) | |
| 953 | - | if templ_7745c5c3_Var47 == nil { | |
| 954 | - | templ_7745c5c3_Var47 = templ.NopComponent | |
| 1021 | + | templ_7745c5c3_Var48 := templ.GetChildren(ctx) | |
| 1022 | + | if templ_7745c5c3_Var48 == nil { | |
| 1023 | + | templ_7745c5c3_Var48 = templ.NopComponent | |
| 955 | 1024 | } | |
| 956 | 1025 | ctx = templ.ClearChildren(ctx) | |
| 957 | 1026 | groups := configKeybindingGroups(keybindings) | |
| @@ -966,7 +1035,15 @@ func ConfigKeybindings(keybindings port.KeybindingsConfig) templ.Component { | |||
| 966 | 1035 | return templ_7745c5c3_Err | |
| 967 | 1036 | } | |
| 968 | 1037 | } else { | |
| 969 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "<div class=\"sv-button-row\"><button class=\"sv-button sv-button-secondary sv-button-danger\" type=\"button\" data-sv-action=\"config.keybinding.resetAll\" data-sv-confirm=\"Reset all keybindings to defaults?\">Reset all keybindings</button></div>") | |
| 1038 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<div class=\"sv-button-row\"><button class=\"sv-button sv-button-secondary sv-button-danger\" type=\"button\" data-sv-action=\"config.keybinding.resetAll\" data-sv-confirm=\"Reset all keybindings to defaults?\">") | |
| 1039 | + | if templ_7745c5c3_Err != nil { | |
| 1040 | + | return templ_7745c5c3_Err | |
| 1041 | + | } | |
| 1042 | + | templ_7745c5c3_Err = ButtonIconLabel(iconRotateCCW, "Reset all keybindings").Render(ctx, templ_7745c5c3_Buffer) | |
| 1043 | + | if templ_7745c5c3_Err != nil { | |
| 1044 | + | return templ_7745c5c3_Err | |
| 1045 | + | } | |
| 1046 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</button></div>") | |
| 970 | 1047 | if templ_7745c5c3_Err != nil { | |
| 971 | 1048 | return templ_7745c5c3_Err | |
| 972 | 1049 | } | |
| @@ -997,48 +1074,48 @@ func ConfigKeybindingGroup(group renderedKeybindingGroup) templ.Component { | |||
| 997 | 1074 | }() | |
| 998 | 1075 | } | |
| 999 | 1076 | ctx = templ.InitializeContext(ctx) | |
| 1000 | - | templ_7745c5c3_Var48 := templ.GetChildren(ctx) | |
| 1001 | - | if templ_7745c5c3_Var48 == nil { | |
| 1002 | - | templ_7745c5c3_Var48 = templ.NopComponent | |
| 1077 | + | templ_7745c5c3_Var49 := templ.GetChildren(ctx) | |
| 1078 | + | if templ_7745c5c3_Var49 == nil { | |
| 1079 | + | templ_7745c5c3_Var49 = templ.NopComponent | |
| 1003 | 1080 | } | |
| 1004 | 1081 | ctx = templ.ClearChildren(ctx) | |
| 1005 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<div class=\"sv-group\"><div class=\"sv-group-header\"><div><h4>") | |
| 1082 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "<div class=\"sv-group\"><div class=\"sv-group-header\"><div><h4>") | |
| 1006 | 1083 | if templ_7745c5c3_Err != nil { | |
| 1007 | 1084 | return templ_7745c5c3_Err | |
| 1008 | 1085 | } | |
| 1009 | - | var templ_7745c5c3_Var49 string | |
| 1010 | - | templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingGroupTitle(group)) | |
| 1086 | + | var templ_7745c5c3_Var50 string | |
| 1087 | + | templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingGroupTitle(group)) | |
| 1011 | 1088 | if templ_7745c5c3_Err != nil { | |
| 1012 | 1089 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 153, Col: 37} | |
| 1013 | 1090 | } | |
| 1014 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49)) | |
| 1091 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50)) | |
| 1015 | 1092 | if templ_7745c5c3_Err != nil { | |
| 1016 | 1093 | return templ_7745c5c3_Err | |
| 1017 | 1094 | } | |
| 1018 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</h4>") | |
| 1095 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "</h4>") | |
| 1019 | 1096 | if templ_7745c5c3_Err != nil { | |
| 1020 | 1097 | return templ_7745c5c3_Err | |
| 1021 | 1098 | } | |
| 1022 | 1099 | if keybindingGroupMeta(group) != "" { | |
| 1023 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "<p class=\"sv-meta\">") | |
| 1100 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "<p class=\"sv-meta\">") | |
| 1024 | 1101 | if templ_7745c5c3_Err != nil { | |
| 1025 | 1102 | return templ_7745c5c3_Err | |
| 1026 | 1103 | } | |
| 1027 | - | var templ_7745c5c3_Var50 string | |
| 1028 | - | templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingGroupMeta(group)) | |
| 1104 | + | var templ_7745c5c3_Var51 string | |
| 1105 | + | templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingGroupMeta(group)) | |
| 1029 | 1106 | if templ_7745c5c3_Err != nil { | |
| 1030 | 1107 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 155, Col: 52} | |
| 1031 | 1108 | } | |
| 1032 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50)) | |
| 1109 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51)) | |
| 1033 | 1110 | if templ_7745c5c3_Err != nil { | |
| 1034 | 1111 | return templ_7745c5c3_Err | |
| 1035 | 1112 | } | |
| 1036 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</p>") | |
| 1113 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</p>") | |
| 1037 | 1114 | if templ_7745c5c3_Err != nil { | |
| 1038 | 1115 | return templ_7745c5c3_Err | |
| 1039 | 1116 | } | |
| 1040 | 1117 | } | |
| 1041 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</div></div><div class=\"sv-group-body\">") | |
| 1118 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</div></div><div class=\"sv-group-body\">") | |
| 1042 | 1119 | if templ_7745c5c3_Err != nil { | |
| 1043 | 1120 | return templ_7745c5c3_Err | |
| 1044 | 1121 | } | |
| @@ -1048,7 +1125,7 @@ func ConfigKeybindingGroup(group renderedKeybindingGroup) templ.Component { | |||
| 1048 | 1125 | return templ_7745c5c3_Err | |
| 1049 | 1126 | } | |
| 1050 | 1127 | } else { | |
| 1051 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<ul class=\"sv-list\">") | |
| 1128 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "<ul class=\"sv-list\">") | |
| 1052 | 1129 | if templ_7745c5c3_Err != nil { | |
| 1053 | 1130 | return templ_7745c5c3_Err | |
| 1054 | 1131 | } | |
| @@ -1058,12 +1135,12 @@ func ConfigKeybindingGroup(group renderedKeybindingGroup) templ.Component { | |||
| 1058 | 1135 | return templ_7745c5c3_Err | |
| 1059 | 1136 | } | |
| 1060 | 1137 | } | |
| 1061 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</ul>") | |
| 1138 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</ul>") | |
| 1062 | 1139 | if templ_7745c5c3_Err != nil { | |
| 1063 | 1140 | return templ_7745c5c3_Err | |
| 1064 | 1141 | } | |
| 1065 | 1142 | } | |
| 1066 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</div></div>") | |
| 1143 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</div></div>") | |
| 1067 | 1144 | if templ_7745c5c3_Err != nil { | |
| 1068 | 1145 | return templ_7745c5c3_Err | |
| 1069 | 1146 | } | |
| @@ -1087,38 +1164,38 @@ func ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding) | |||
| 1087 | 1164 | }() | |
| 1088 | 1165 | } | |
| 1089 | 1166 | ctx = templ.InitializeContext(ctx) | |
| 1090 | - | templ_7745c5c3_Var51 := templ.GetChildren(ctx) | |
| 1091 | - | if templ_7745c5c3_Var51 == nil { | |
| 1092 | - | templ_7745c5c3_Var51 = templ.NopComponent | |
| 1167 | + | templ_7745c5c3_Var52 := templ.GetChildren(ctx) | |
| 1168 | + | if templ_7745c5c3_Var52 == nil { | |
| 1169 | + | templ_7745c5c3_Var52 = templ.NopComponent | |
| 1093 | 1170 | } | |
| 1094 | 1171 | ctx = templ.ClearChildren(ctx) | |
| 1095 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<li class=\"sv-list-row\"><div class=\"sv-keybinding-row\"><div><strong>") | |
| 1172 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<li class=\"sv-list-row\"><div class=\"sv-keybinding-row\"><div><strong>") | |
| 1096 | 1173 | if templ_7745c5c3_Err != nil { | |
| 1097 | 1174 | return templ_7745c5c3_Err | |
| 1098 | 1175 | } | |
| 1099 | - | var templ_7745c5c3_Var52 string | |
| 1100 | - | templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingDescription(binding)) | |
| 1176 | + | var templ_7745c5c3_Var53 string | |
| 1177 | + | templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingDescription(binding)) | |
| 1101 | 1178 | if templ_7745c5c3_Err != nil { | |
| 1102 | 1179 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 177, Col: 44} | |
| 1103 | 1180 | } | |
| 1104 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52)) | |
| 1181 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53)) | |
| 1105 | 1182 | if templ_7745c5c3_Err != nil { | |
| 1106 | 1183 | return templ_7745c5c3_Err | |
| 1107 | 1184 | } | |
| 1108 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</strong><p class=\"sv-meta\">") | |
| 1185 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "</strong><p class=\"sv-meta\">") | |
| 1109 | 1186 | if templ_7745c5c3_Err != nil { | |
| 1110 | 1187 | return templ_7745c5c3_Err | |
| 1111 | 1188 | } | |
| 1112 | - | var templ_7745c5c3_Var53 string | |
| 1113 | - | templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingAction(binding)) | |
| 1189 | + | var templ_7745c5c3_Var54 string | |
| 1190 | + | templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingAction(binding)) | |
| 1114 | 1191 | if templ_7745c5c3_Err != nil { | |
| 1115 | 1192 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 178, Col: 50} | |
| 1116 | 1193 | } | |
| 1117 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53)) | |
| 1194 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54)) | |
| 1118 | 1195 | if templ_7745c5c3_Err != nil { | |
| 1119 | 1196 | return templ_7745c5c3_Err | |
| 1120 | 1197 | } | |
| 1121 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "</p><div class=\"sv-binding-keys\">") | |
| 1198 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "</p><div class=\"sv-binding-keys\">") | |
| 1122 | 1199 | if templ_7745c5c3_Err != nil { | |
| 1123 | 1200 | return templ_7745c5c3_Err | |
| 1124 | 1201 | } | |
| @@ -1126,25 +1203,25 @@ func ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding) | |||
| 1126 | 1203 | if templ_7745c5c3_Err != nil { | |
| 1127 | 1204 | return templ_7745c5c3_Err | |
| 1128 | 1205 | } | |
| 1129 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "<span class=\"sv-badge\">") | |
| 1206 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "<span class=\"sv-badge\">") | |
| 1130 | 1207 | if templ_7745c5c3_Err != nil { | |
| 1131 | 1208 | return templ_7745c5c3_Err | |
| 1132 | 1209 | } | |
| 1133 | - | var templ_7745c5c3_Var54 string | |
| 1134 | - | templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingStatus(binding)) | |
| 1210 | + | var templ_7745c5c3_Var55 string | |
| 1211 | + | templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingStatus(binding)) | |
| 1135 | 1212 | if templ_7745c5c3_Err != nil { | |
| 1136 | 1213 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 181, Col: 55} | |
| 1137 | 1214 | } | |
| 1138 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54)) | |
| 1215 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55)) | |
| 1139 | 1216 | if templ_7745c5c3_Err != nil { | |
| 1140 | 1217 | return templ_7745c5c3_Err | |
| 1141 | 1218 | } | |
| 1142 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</span> ") | |
| 1219 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "</span> ") | |
| 1143 | 1220 | if templ_7745c5c3_Err != nil { | |
| 1144 | 1221 | return templ_7745c5c3_Err | |
| 1145 | 1222 | } | |
| 1146 | 1223 | if keybindingIsCustom(binding) && len(binding.DefaultKeys) > 0 { | |
| 1147 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "<span class=\"sv-meta\">default:</span>") | |
| 1224 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "<span class=\"sv-meta\">default:</span>") | |
| 1148 | 1225 | if templ_7745c5c3_Err != nil { | |
| 1149 | 1226 | return templ_7745c5c3_Err | |
| 1150 | 1227 | } | |
| @@ -1153,72 +1230,88 @@ func ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding) | |||
| 1153 | 1230 | return templ_7745c5c3_Err | |
| 1154 | 1231 | } | |
| 1155 | 1232 | } | |
| 1156 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</div></div><form class=\"sv-inline-form\" data-sv-action=\"config.keybinding.set\"><input type=\"hidden\" name=\"mode\" value=\"") | |
| 1233 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "</div></div><form class=\"sv-inline-form\" data-sv-action=\"config.keybinding.set\"><input type=\"hidden\" name=\"mode\" value=\"") | |
| 1157 | 1234 | if templ_7745c5c3_Err != nil { | |
| 1158 | 1235 | return templ_7745c5c3_Err | |
| 1159 | 1236 | } | |
| 1160 | - | var templ_7745c5c3_Var55 string | |
| 1161 | - | templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(group.Mode) | |
| 1237 | + | var templ_7745c5c3_Var56 string | |
| 1238 | + | templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(group.Mode) | |
| 1162 | 1239 | if templ_7745c5c3_Err != nil { | |
| 1163 | 1240 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 189, Col: 55} | |
| 1164 | 1241 | } | |
| 1165 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55)) | |
| 1242 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56)) | |
| 1166 | 1243 | if templ_7745c5c3_Err != nil { | |
| 1167 | 1244 | return templ_7745c5c3_Err | |
| 1168 | 1245 | } | |
| 1169 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "\"> <input type=\"hidden\" name=\"action\" value=\"") | |
| 1246 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "\"> <input type=\"hidden\" name=\"action\" value=\"") | |
| 1170 | 1247 | if templ_7745c5c3_Err != nil { | |
| 1171 | 1248 | return templ_7745c5c3_Err | |
| 1172 | 1249 | } | |
| 1173 | - | var templ_7745c5c3_Var56 string | |
| 1174 | - | templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(binding.Action) | |
| 1250 | + | var templ_7745c5c3_Var57 string | |
| 1251 | + | templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(binding.Action) | |
| 1175 | 1252 | if templ_7745c5c3_Err != nil { | |
| 1176 | 1253 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 190, Col: 61} | |
| 1177 | 1254 | } | |
| 1178 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56)) | |
| 1255 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57)) | |
| 1179 | 1256 | if templ_7745c5c3_Err != nil { | |
| 1180 | 1257 | return templ_7745c5c3_Err | |
| 1181 | 1258 | } | |
| 1182 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\"> <label><span>Keys</span><input name=\"keys\" type=\"text\" value=\"") | |
| 1259 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "\"> <label><span>Keys</span><input name=\"keys\" type=\"text\" value=\"") | |
| 1183 | 1260 | if templ_7745c5c3_Err != nil { | |
| 1184 | 1261 | return templ_7745c5c3_Err | |
| 1185 | 1262 | } | |
| 1186 | - | var templ_7745c5c3_Var57 string | |
| 1187 | - | templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingKeysValue(binding.Keys)) | |
| 1263 | + | var templ_7745c5c3_Var58 string | |
| 1264 | + | templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingKeysValue(binding.Keys)) | |
| 1188 | 1265 | if templ_7745c5c3_Err != nil { | |
| 1189 | 1266 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 191, Col: 100} | |
| 1190 | 1267 | } | |
| 1191 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57)) | |
| 1268 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58)) | |
| 1192 | 1269 | if templ_7745c5c3_Err != nil { | |
| 1193 | 1270 | return templ_7745c5c3_Err | |
| 1194 | 1271 | } | |
| 1195 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "\" placeholder=\"ctrl+h, ctrl+shift+h\" required></label> <button class=\"sv-button sv-button-secondary\" type=\"submit\">Save</button> <button class=\"sv-button sv-button-secondary\" type=\"button\" data-sv-action=\"config.keybinding.reset\" data-mode=\"") | |
| 1272 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "\" placeholder=\"ctrl+h, ctrl+shift+h\" required></label> <button class=\"sv-button sv-button-secondary\" type=\"submit\">") | |
| 1196 | 1273 | if templ_7745c5c3_Err != nil { | |
| 1197 | 1274 | return templ_7745c5c3_Err | |
| 1198 | 1275 | } | |
| 1199 | - | var templ_7745c5c3_Var58 string | |
| 1200 | - | templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(group.Mode) | |
| 1276 | + | templ_7745c5c3_Err = ButtonIconLabel(iconSave, "Save").Render(ctx, templ_7745c5c3_Buffer) | |
| 1277 | + | if templ_7745c5c3_Err != nil { | |
| 1278 | + | return templ_7745c5c3_Err | |
| 1279 | + | } | |
| 1280 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "</button> <button class=\"sv-button sv-button-secondary\" type=\"button\" data-sv-action=\"config.keybinding.reset\" data-mode=\"") | |
| 1281 | + | if templ_7745c5c3_Err != nil { | |
| 1282 | + | return templ_7745c5c3_Err | |
| 1283 | + | } | |
| 1284 | + | var templ_7745c5c3_Var59 string | |
| 1285 | + | templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(group.Mode) | |
| 1201 | 1286 | if templ_7745c5c3_Err != nil { | |
| 1202 | 1287 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 193, Col: 127} | |
| 1203 | 1288 | } | |
| 1204 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58)) | |
| 1289 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59)) | |
| 1205 | 1290 | if templ_7745c5c3_Err != nil { | |
| 1206 | 1291 | return templ_7745c5c3_Err | |
| 1207 | 1292 | } | |
| 1208 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "\" data-action=\"") | |
| 1293 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "\" data-action=\"") | |
| 1209 | 1294 | if templ_7745c5c3_Err != nil { | |
| 1210 | 1295 | return templ_7745c5c3_Err | |
| 1211 | 1296 | } | |
| 1212 | - | var templ_7745c5c3_Var59 string | |
| 1213 | - | templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(binding.Action) | |
| 1297 | + | var templ_7745c5c3_Var60 string | |
| 1298 | + | templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(binding.Action) | |
| 1214 | 1299 | if templ_7745c5c3_Err != nil { | |
| 1215 | 1300 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 193, Col: 158} | |
| 1216 | 1301 | } | |
| 1217 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59)) | |
| 1302 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60)) | |
| 1303 | + | if templ_7745c5c3_Err != nil { | |
| 1304 | + | return templ_7745c5c3_Err | |
| 1305 | + | } | |
| 1306 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "\">") | |
| 1307 | + | if templ_7745c5c3_Err != nil { | |
| 1308 | + | return templ_7745c5c3_Err | |
| 1309 | + | } | |
| 1310 | + | templ_7745c5c3_Err = ButtonIconLabel(iconRotateCCW, "Reset").Render(ctx, templ_7745c5c3_Buffer) | |
| 1218 | 1311 | if templ_7745c5c3_Err != nil { | |
| 1219 | 1312 | return templ_7745c5c3_Err | |
| 1220 | 1313 | } | |
| 1221 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "\">Reset</button></form></div></li>") | |
| 1314 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "</button></form></div></li>") | |
| 1222 | 1315 | if templ_7745c5c3_Err != nil { | |
| 1223 | 1316 | return templ_7745c5c3_Err | |
| 1224 | 1317 | } | |
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 generated file: collapsed
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 | + | } | |