detached ff220dd

fix(systemviews): refine history lazy loading

commit
ff220dd440157b14b1fe4037265aa29df937be9c
author
brice · · 2026-04-25 15:11 UTC
parent
a4e770b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
47 files · +4455 -725
M assets/systemviews/systemviews.css +188 -5
@@ -203,6 +203,43 @@ body {
203203 color: var(--sv-muted, #9aa3b2);
204204 }
205205
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+
206243 .sv-stat-grid {
207244 display: grid;
208245 grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
@@ -240,14 +277,23 @@ body {
240277 }
241278
242279 .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;
246286 border: 1px solid var(--sv-border, #2a313d);
247287 border-radius: var(--sv-radius-chip, 0.45rem);
248288 background: var(--sv-surface-variant, #1d2129);
249289 }
250290
291+.sv-domain-chip-main {
292+ display: grid;
293+ gap: 0.15rem;
294+ min-width: 0;
295+}
296+
251297 .sv-domain-meta,
252298 .sv-history-meta,
253299 .sv-history-url {
@@ -274,6 +320,46 @@ body {
274320 align-items: start;
275321 }
276322
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+
277363 .sv-history-main {
278364 min-width: 0;
279365 }
@@ -284,7 +370,17 @@ body {
284370 }
285371
286372 .sv-history-url {
373+ display: -webkit-box;
374+ max-width: 100%;
375+ overflow: hidden;
287376 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);
288384 }
289385
290386 .sv-error {
@@ -397,6 +493,7 @@ body {
397493 display: inline-flex;
398494 align-items: center;
399495 justify-content: center;
496+ gap: 0.4rem;
400497 min-height: 2rem;
401498 border: 1px solid var(--sv-accent, #8ab4ff);
402499 border-radius: var(--sv-radius-control, 0.35rem);
@@ -430,9 +527,33 @@ body {
430527 opacity: 0.7;
431528 }
432529
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+
433553 .sv-button:focus-visible,
434554 .sv-icon-button:focus-visible,
435555 .sv-domain-filter:focus-visible,
556+.sv-danger-link:focus-visible,
436557 .sv-filter-chip button:focus-visible,
437558 .sv-link:focus-visible,
438559 .sv-search-label input:focus-visible,
@@ -482,11 +603,35 @@ body {
482603 display: inline-flex;
483604 align-items: center;
484605 justify-content: center;
485- width: 1.4rem;
486- height: 1.4rem;
606+ width: 1.75rem;
607+ height: 1.75rem;
487608 border-radius: 999px;
488609 }
489610
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+
490635 .sv-danger {
491636 color: var(--sv-danger-text, #fecaca);
492637 }
@@ -497,6 +642,44 @@ body {
497642 background: color-mix(in srgb, var(--sv-accent, #8ab4ff) 12%, transparent);
498643 }
499644
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+
500683 .sv-history-item.sv-focused {
501684 outline: 2px solid var(--sv-accent, #8ab4ff);
502685 outline-offset: 0.35rem;
M cmd/systemviews/bootstrap_test.go +11 -1
@@ -3,6 +3,7 @@ package main
33 import (
44 "context"
55 "testing"
6+ "time"
67
78 "github.com/bnema/dumber/internal/application/dto"
89 "github.com/bnema/dumber/internal/application/port"
@@ -48,7 +49,7 @@ func TestNewBridgeApp_UsesCurrentConfigForNonConfigRoutes(t *testing.T) {
4849 app := newBridgeApp(dom, "dumb://history", bridge)
4950
5051 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)
5253 assert.True(t, bridge.calledConfig)
5354 assert.False(t, bridge.calledKeybindings)
5455 assert.Contains(t, dom.html, `class="sv-app sv-dark"`)
@@ -123,6 +124,11 @@ func (f *fakeBridgeService) TimelineByDomain(context.Context, string, int, int)
123124 return f.historyEntries, nil
124125 }
125126
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+
126132 func (*fakeBridgeService) Search(context.Context, string, int) ([]*entity.HistoryEntry, error) {
127133 return nil, nil
128134 }
@@ -131,6 +137,10 @@ func (*fakeBridgeService) DeleteEntry(context.Context, int64) error { return nil
131137
132138 func (*fakeBridgeService) DeleteRange(context.Context, string) error { return nil }
133139
140+func (*fakeBridgeService) Stats(context.Context) (*entity.HistoryStats, error) {
141+ return nil, nil
142+}
143+
134144 func (*fakeBridgeService) Analytics(context.Context) (*entity.HistoryAnalytics, error) {
135145 return nil, nil
136146 }
M internal/application/port/favicon_service.go +7 -0
@@ -9,6 +9,7 @@ type FaviconService interface {
99 DiskPathPNG(domain string) string
1010 HasPNGOnDisk(domain string) bool
1111 HasPNGSizedOnDisk(domain string, size int) bool
12+ DiskPathPNGSized(domain string, size int) string
1213 EnsureSizedPNG(ctx context.Context, domain string, size int) error
1314 EnsureCacheDir() error
1415 // EnsureDiskCache ensures a favicon is written to the on-disk cache for the given
@@ -17,3 +18,9 @@ type FaviconService interface {
1718 EnsureDiskCache(ctx context.Context, domain string)
1819 Close()
1920 }
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 {
2222 type HomepageHistory interface {
2323 GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
2424 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)
2526 Search(ctx context.Context, input HistorySearchInput) (*HistorySearchOutput, error)
2627 Delete(ctx context.Context, id int64) error
2728 ClearRange(ctx context.Context, rangeID string) error
2829 ClearAll(ctx context.Context) error
2930 ClearOlderThan(ctx context.Context, before time.Time) error
31+ GetStats(ctx context.Context) (*entity.HistoryStats, error)
3032 GetAnalytics(ctx context.Context) (*entity.HistoryAnalytics, error)
3133 GetDomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error)
3234 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
50525052 return _c
50535053 }
50545054
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+
50555112 // EnsureCacheDir provides a mock function for the type MockFaviconService
50565113 func (_mock *MockFaviconService) EnsureCacheDir() error {
50575114 ret := _mock.Called()
@@ -7002,6 +7059,142 @@ func (_c *MockHomepageHistory_GetRecentByDomain_Call) RunAndReturn(run func(ctx
70027059 return _c
70037060 }
70047061
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+
70057198 // Search provides a mock function for the type MockHomepageHistory
70067199 func (_mock *MockHomepageHistory) Search(ctx context.Context, input port.HistorySearchInput) (*port.HistorySearchOutput, error) {
70077200 ret := _mock.Called(ctx, input)
M internal/application/port/systemviews.go +3 -0
@@ -2,6 +2,7 @@ package port
22
33 import (
44 "context"
5+ "time"
56
67 "github.com/bnema/dumber/internal/application/dto"
78 "github.com/bnema/dumber/internal/domain/entity"
@@ -11,9 +12,11 @@ import (
1112 type SystemviewHistoryService interface {
1213 Timeline(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
1314 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)
1416 Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error)
1517 DeleteEntry(ctx context.Context, id int64) error
1618 DeleteRange(ctx context.Context, rangeID string) error
19+ Stats(ctx context.Context) (*entity.HistoryStats, error)
1720 Analytics(ctx context.Context) (*entity.HistoryAnalytics, error)
1821 DomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error)
1922 DeleteDomain(ctx context.Context, domain string) error
M internal/application/usecase/search_history.go +55 -5
@@ -16,6 +16,8 @@ import (
1616 // Compile-time check: SearchHistoryUseCase must satisfy port.HomepageHistory.
1717 var _ port.HomepageHistory = (*SearchHistoryUseCase)(nil)
1818
19+const historyWindowDuration = 24 * time.Hour
20+
1921 // SearchHistoryUseCase handles history search and retrieval operations.
2022 type SearchHistoryUseCase struct {
2123 historyRepo repository.HistoryRepository
@@ -62,10 +64,11 @@ func (uc *SearchHistoryUseCase) Search(ctx context.Context, input SearchInput) (
6264 return &SearchOutput{Matches: matches}, nil
6365 }
6466
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.
6669 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.
6972 }
7073
7174 entries, err := uc.historyRepo.GetRecent(ctx, limit, offset)
@@ -76,9 +79,10 @@ func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int
7679 return entries, nil
7780 }
7881
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.
8084 func (uc *SearchHistoryUseCase) GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) {
81- if limit <= 0 {
85+ if limit < 0 {
8286 limit = 50
8387 }
8488 domain, err := canonicalHistoryDomain(domain)
@@ -103,6 +107,43 @@ func canonicalHistoryDomain(domain string) (string, error) {
103107 return domain, nil
104108 }
105109
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+
106147 // GetRecentSince retrieves history entries visited within the last N days.
107148 // If days is 0, returns all history entries.
108149 // If days is negative, defaults to 30 days.
@@ -258,6 +299,15 @@ func (uc *SearchHistoryUseCase) GetDomainStats(ctx context.Context, limit int) (
258299 return uc.historyRepo.GetDomainStats(ctx, limit)
259300 }
260301
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+
261311 // GetAnalytics retrieves aggregated history analytics for the homepage.
262312 func (uc *SearchHistoryUseCase) GetAnalytics(ctx context.Context) (*entity.HistoryAnalytics, error) {
263313 log := logging.FromContext(ctx)
M internal/application/usecase/search_history_test.go +25 -0
@@ -13,6 +13,31 @@ import (
1313 "github.com/stretchr/testify/require"
1414 )
1515
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+
1641 func TestSearchHistoryUseCase_GetRecentSince_ReturnsEntries(t *testing.T) {
1742 ctx := testContext()
1843
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 {
1818 // Search performs a fuzzy search on history entries.
1919 Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error)
2020
21- // GetRecent retrieves recent history entries with pagination.
21+ // GetRecent retrieves recent history entries with pagination. A zero limit means all entries.
2222 GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
2323
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.
2525 GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error)
2626
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+
2739 // GetRecentSince retrieves history entries visited within the last N days.
2840 // days must be > 0.
2941 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
28662866 return _c
28672867 }
28682868
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+
28693023 // GetStats provides a mock function for the type MockHistoryRepository
28703024 func (_mock *MockHistoryRepository) GetStats(ctx context.Context) (*entity.HistoryStats, error) {
28713025 ret := _mock.Called(ctx)
@@ -2928,6 +3082,144 @@ func (_c *MockHistoryRepository_GetStats_Call) RunAndReturn(run func(ctx context
29283082 return _c
29293083 }
29303084
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+
29313223 // IncrementVisitCount provides a mock function for the type MockHistoryRepository
29323224 func (_mock *MockHistoryRepository) IncrementVisitCount(ctx context.Context, url string) error {
29333225 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 (
1313 "net"
1414 "net/http"
1515 "net/url"
16+ "os"
1617 "path"
18+ "strconv"
1719 "strings"
1820 "sync"
1921 "time"
@@ -23,6 +25,7 @@ import (
2325 purecef "github.com/bnema/purego-cef/cef"
2426
2527 "github.com/bnema/dumber/internal/application/port"
28+ domainurl "github.com/bnema/dumber/internal/domain/url"
2629 "github.com/bnema/dumber/internal/infrastructure/netguard"
2730 "github.com/bnema/dumber/internal/infrastructure/webutil"
2831 "github.com/bnema/dumber/internal/logging"
@@ -57,6 +60,7 @@ type dumbSchemeHandler struct {
5760 ctx context.Context
5861 messageRouter *MessageRouter
5962 transcoder port.MediaTranscoder
63+ faviconService port.FaviconService
6064 assets embed.FS
6165 assetsSet bool
6266 assetDir string
@@ -123,6 +127,12 @@ func (h *dumbSchemeHandler) setAssets(assets embed.FS) {
123127 h.logger.Debug().Msg("assets filesystem configured")
124128 }
125129
130+func (h *dumbSchemeHandler) setFaviconService(service port.FaviconService) {
131+ h.mu.Lock()
132+ defer h.mu.Unlock()
133+ h.faviconService = service
134+}
135+
126136 // Create implements purecef.SchemeHandlerFactory. Called on the CEF IO thread
127137 // for requests to either dumb:// or the internal https origin.
128138 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
171181 // handleAPI routes API requests to the message router or built-in handlers.
172182 func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path string, request purecef.Request) purecef.ResourceHandler {
173183 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+ }
174190 return h.newAPIRawResourceHandler(http.StatusNoContent, "text/plain; charset=utf-8", nil)
175191 }
176192 if isAPIGetMethod(method) {
@@ -197,17 +213,19 @@ func isAPIPostMethod(method string) bool {
197213 func (h *dumbSchemeHandler) handleAPIGet(path string, request purecef.Request) (purecef.ResourceHandler, bool) {
198214 switch path {
199215 case "/api/config":
200- if denied := h.rejectForbiddenAPIOrigin(request); denied != nil {
216+ if denied := h.rejectUntrustedConfigRequester(request); denied != nil {
201217 return denied, true
202218 }
203219 return h.handleConfigAPI(h.currentConfigPayload), true
204220 case "/api/config/default":
205- if denied := h.rejectForbiddenAPIOrigin(request); denied != nil {
221+ if denied := h.rejectUntrustedConfigRequester(request); denied != nil {
206222 return denied, true
207223 }
208224 return h.handleConfigAPI(h.defaultConfigPayload), true
209225 case "/api/transcode":
210226 return h.handleTranscodeAPI(request), true
227+ case "/api/favicon":
228+ return h.handleFaviconAPI(request), true
211229 default:
212230 return nil, false
213231 }
@@ -259,6 +277,103 @@ func (h *dumbSchemeHandler) rejectForbiddenAPIOrigin(request purecef.Request) pu
259277 return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden origin"})
260278 }
261279
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+
262377 func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.ResourceHandler {
263378 if h.transcoder == nil {
264379 return h.newAPIJSONResourceHandler(http.StatusServiceUnavailable, map[string]string{"error": "transcoder unavailable"})
@@ -357,9 +472,9 @@ func resolveConfigPayload(build func() ([]byte, error)) ([]byte, error) {
357472 func (h *dumbSchemeHandler) handleConfigAPI(build func() ([]byte, error)) purecef.ResourceHandler {
358473 data, err := resolveConfigPayload(build)
359474 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()})
361476 }
362- return h.newAPIRawResourceHandler(http.StatusOK, "application/json", data)
477+ return h.newPrivateAPIRawResourceHandler(http.StatusOK, "application/json", data)
363478 }
364479
365480 // handleClipboardSet receives copied text from JS copy/cut events and writes
@@ -789,6 +904,10 @@ func (h *dumbSchemeHandler) newAPIRawResourceHandler(status int, contentType str
789904 return newStaticResourceHandler(status, contentType, data, apiResponseHeaders(nil))
790905 }
791906
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+
792911 func (h *dumbSchemeHandler) newErrorResourceHandler(status int, msg string) purecef.ResourceHandler {
793912 escaped := html.EscapeString(msg)
794913 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
804923 return h.newAPIRawResourceHandler(status, "application/json", data)
805924 }
806925
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+
8071054 // Open handles the request immediately (synchronous).
8081055 func (rh *staticResourceHandler) Open(_ purecef.Request, handleRequest *int32, _ purecef.Callback) int32 {
8091056 // 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) {
2626 response.EXPECT().SetHeaderByName("Cache-Control", "no-store", int32(1)).Once()
2727 }
2828
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+
2954 func TestResolveConfigPayload_UsesInjectedBuilder(t *testing.T) {
3055 data, err := resolveConfigPayload(func() ([]byte, error) {
3156 return []byte(`{"engine_type":"cef"}`), nil
@@ -96,6 +121,168 @@ func TestRejectForbiddenAPIOrigin_RejectsExternalOrigin(t *testing.T) {
96121 require.Positive(t, responseLength)
97122 }
98123
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+
99286 func TestValidateTranscodeSourceURL_RejectsPrivateHosts(t *testing.T) {
100287 privateURL, err := url.Parse("http://127.0.0.1/video.mp4")
101288 require.NoError(t, err)
M internal/infrastructure/handlers/homepage/history.go +72 -0
@@ -5,6 +5,7 @@ import (
55 "encoding/json"
66 "fmt"
77 "strings"
8+ "time"
89
910 "github.com/bnema/dumber/internal/application/port"
1011 "github.com/bnema/dumber/internal/domain/entity"
@@ -35,6 +36,12 @@ type timelineByDomainRequest struct {
3536 Offset int `json:"offset"`
3637 }
3738
39+type timelineWindowRequest struct {
40+ RequestID string `json:"requestId"`
41+ Before string `json:"before"`
42+ Domain string `json:"domain"`
43+}
44+
3845 // HandleTimeline handles history_timeline messages.
3946 func (h *HistoryHandlers) HandleTimeline() port.WebUIMessageHandler {
4047 return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
@@ -50,6 +57,9 @@ func (h *HistoryHandlers) HandleTimeline() port.WebUIMessageHandler {
5057 Int("limit", req.Limit).
5158 Int("offset", req.Offset).
5259 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+ }
5363
5464 entries, err := h.historyUC.GetRecent(ctx, req.Limit, req.Offset)
5565 if err != nil {
@@ -81,6 +91,9 @@ func (h *HistoryHandlers) HandleTimelineByDomain() port.WebUIMessageHandler {
8191 Int("limit", req.Limit).
8292 Int("offset", req.Offset).
8393 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+ }
8497
8598 entries, err := h.historyUC.GetRecentByDomain(ctx, req.Domain, req.Limit, req.Offset)
8699 if err != nil {
@@ -91,6 +104,42 @@ func (h *HistoryHandlers) HandleTimelineByDomain() port.WebUIMessageHandler {
91104 })
92105 }
93106
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+
94143 // searchRequest is the payload for history_search_fts messages.
95144 type searchRequest struct {
96145 RequestID string `json:"requestId"`
@@ -209,6 +258,29 @@ func (h *HistoryHandlers) HandleClearAll() port.WebUIMessageHandler {
209258 })
210259 }
211260
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+
212284 // HandleAnalytics handles history_analytics messages.
213285 func (h *HistoryHandlers) HandleAnalytics() port.WebUIMessageHandler {
214286 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
3030 historyHandlers := NewHistoryHandlers(cfg.HistoryUC)
3131 handlers["history_timeline"] = historyHandlers.HandleTimeline()
3232 handlers["history_timeline_by_domain"] = historyHandlers.HandleTimelineByDomain()
33+ handlers["history_timeline_window"] = historyHandlers.HandleTimelineWindow()
3334 handlers["history_search_fts"] = historyHandlers.HandleSearchFTS()
3435 handlers["history_delete_entry"] = historyHandlers.HandleDeleteEntry()
3536 handlers["history_delete_range"] = historyHandlers.HandleDeleteRange()
3637 handlers["history_clear_all"] = historyHandlers.HandleClearAll()
38+ handlers["history_stats"] = historyHandlers.HandleStats()
3739 handlers["history_analytics"] = historyHandlers.HandleAnalytics()
3840 handlers["history_domain_stats"] = historyHandlers.HandleDomainStats()
3941 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 {
483483 }
484484
485485 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+ }
486493 rows, err := r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{
487494 Limit: int64(limit),
488495 Offset: int64(offset),
@@ -499,6 +506,13 @@ func (r *historyRepo) GetRecentByDomain(ctx context.Context, domain string, limi
499506 if domain == "" {
500507 return nil, fmt.Errorf("history domain is required: %q", rawDomain)
501508 }
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+ }
502516 rows, err := r.queries.GetRecentHistoryByDomain(ctx, sqlc.GetRecentHistoryByDomainParams{
503517 Domain: sql.NullString{String: domain, Valid: true},
504518 Limit: int64(limit),
@@ -510,6 +524,58 @@ func (r *historyRepo) GetRecentByDomain(ctx context.Context, domain string, limi
510524 return historyEntriesFromRows(rows), nil
511525 }
512526
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+
513579 func (r *historyRepo) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
514580 if days <= 0 {
515581 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) {
191191 require.NoError(t, err)
192192 require.Len(t, recent, 1)
193193
194+ allRecent, err := repo.GetRecent(ctx, 0, 0)
195+ require.NoError(t, err)
196+ require.Len(t, allRecent, 1)
197+
194198 // Delete
195199 require.NoError(t, repo.Delete(ctx, found.ID))
196200
@@ -862,7 +866,7 @@ func TestHistoryRepository_DomainColumnPowersFilteringStatsAndDelete(t *testing.
862866 require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{URL: "https://example.com/b", Title: "Example B"}))
863867 require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{URL: "https://other.test", Title: "Other"}))
864868
865- exampleEntries, err := repo.GetRecentByDomain(ctx, "www.example.com", 10, 0)
869+ exampleEntries, err := repo.GetRecentByDomain(ctx, "www.example.com", 0, 0)
866870 require.NoError(t, err)
867871 require.Len(t, exampleEntries, 2)
868872 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
156156 return r.repo.GetRecentByDomain(ctx, domain, limit, offset)
157157 }
158158
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+
159187 func (r *LazyHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
160188 if err := r.init(ctx); err != nil {
161189 return nil, err
M internal/infrastructure/persistence/sqlite/queries/history.sql +21 -0
@@ -7,6 +7,27 @@ WHERE domain = @domain
77 ORDER BY last_visited DESC
88 LIMIT @limit OFFSET @offset;
99
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+
1031 -- name: GetHistoryByURL :one
1132 SELECT * FROM history WHERE url = ? LIMIT 1;
1233
M internal/infrastructure/persistence/sqlite/sqlc/history.sql.go +152 -0
@@ -144,6 +144,44 @@ func (q *Queries) GetAllRecentHistory(ctx context.Context) ([]History, error) {
144144 return items, nil
145145 }
146146
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+
147185 const GetDailyVisitCount = `-- name: GetDailyVisitCount :many
148186 SELECT
149187 date(last_visited) as day,
@@ -472,6 +510,120 @@ func (q *Queries) GetRecentHistorySince(ctx context.Context, datetime interface{
472510 return items, nil
473511 }
474512
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+
475627 const IncrementVisitCount = `-- name: IncrementVisitCount :exec
476628 UPDATE history
477629 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 {
3737 GetAllFolders(ctx context.Context) ([]FavoriteFolder, error)
3838 GetAllMostVisited(ctx context.Context) ([]History, error)
3939 GetAllRecentHistory(ctx context.Context) ([]History, error)
40+ GetAllRecentHistoryByDomain(ctx context.Context, domain sql.NullString) ([]History, error)
4041 GetAllSessionStates(ctx context.Context) ([]SessionState, error)
4142 GetAllTags(ctx context.Context) ([]FavoriteTag, error)
4243 GetAllWhitelistedDomains(ctx context.Context) ([]string, error)
@@ -57,6 +58,8 @@ type Querier interface {
5758 GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error)
5859 GetRecentHistoryByDomain(ctx context.Context, arg GetRecentHistoryByDomainParams) ([]History, error)
5960 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)
6063 GetRecentSessions(ctx context.Context, limit int64) ([]Session, error)
6164 GetRootFolders(ctx context.Context) ([]FavoriteFolder, error)
6265 GetSessionByID(ctx context.Context, id string) (Session, error)
@@ -67,6 +70,8 @@ type Querier interface {
6770 GetTagsForFavorite(ctx context.Context, favoriteID int64) ([]FavoriteTag, error)
6871 GetTotalSessionStatesSize(ctx context.Context) (interface{}, error)
6972 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)
7075 IncrementVisitCount(ctx context.Context, url string) error
7176 IncrementVisitCountByDelta(ctx context.Context, arg IncrementVisitCountByDeltaParams) error
7277 InsertSession(ctx context.Context, arg InsertSessionParams) error
M internal/infrastructure/systemviewsbridge/browser_transport_js.go +1 -1
@@ -24,7 +24,7 @@ func NewBrowserClient() *Client {
2424
2525 func callbackPlanForMessage(msgType string) (callbackPlan, bool) {
2626 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",
2828 "history_domain_stats", "history_delete_domain", "favorite_list", "favorite_create", "favorite_update", "favorite_delete", "folder_list", "tag_list",
2929 "favorite_set_shortcut", "favorite_set_folder", "folder_create", "folder_update", "folder_delete",
3030 "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
108108 }{RequestID: nextRequestID(), Domain: domain, Limit: limit, Offset: offset})
109109 }
110110
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+
111123 func (c *Client) Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error) {
112124 if limit < 0 {
113125 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 {
185197 return err
186198 }
187199
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+
188206 func (c *Client) Analytics(ctx context.Context) (*entity.HistoryAnalytics, error) {
189207 return request[*entity.HistoryAnalytics](c, ctx, "history_analytics", struct {
190208 RequestID string `json:"requestId"`
M internal/infrastructure/systemviewsbridge/client_test.go +27 -0
@@ -127,6 +127,33 @@ func TestClientTimelineDecodesEntries(t *testing.T) {
127127 }
128128 }
129129
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+
130157 func TestClientDeleteRangeRejectsEmptyRange(t *testing.T) {
131158 t.Parallel()
132159
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 (
99 "io/fs"
1010 "net/http"
1111 "net/url"
12+ "os"
1213 "path"
14+ "strconv"
1315 "strings"
1416 "sync"
1517
1618 "github.com/andybalholm/brotli"
19+ "github.com/bnema/dumber/internal/application/port"
20+ domainurl "github.com/bnema/dumber/internal/domain/url"
1721 "github.com/bnema/dumber/internal/infrastructure/webutil"
1822 "github.com/bnema/dumber/internal/logging"
1923 "github.com/bnema/puregotk/v4/gio"
@@ -37,19 +41,22 @@ const (
3741
3842 // SchemeRequest represents a request to a custom URI scheme.
3943 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
4551 }
4652
4753 // SchemeResponse represents a response to a scheme request.
4854 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
5360 }
5461
5562 // PageHandler generates content for a specific page path.
@@ -68,6 +75,7 @@ func (f PageHandlerFunc) Handle(req *SchemeRequest) *SchemeResponse {
6875 type DumbSchemeHandler struct {
6976 handlers map[string]PageHandler
7077 assets embed.FS
78+ faviconService port.FaviconService
7179 assetDir string // default subdirectory within embed.FS (e.g., "systemviews")
7280 logger zerolog.Logger
7381 mu sync.RWMutex
@@ -108,6 +116,12 @@ func (h *DumbSchemeHandler) SetConfigPayloadBuilders(current, defaultPayload fun
108116 h.logger.Debug().Msg("config payload builders configured")
109117 }
110118
119+func (h *DumbSchemeHandler) SetFaviconService(service port.FaviconService) {
120+ h.mu.Lock()
121+ defer h.mu.Unlock()
122+ h.faviconService = service
123+}
124+
111125 // registerDefaults sets up default page handlers.
112126 func (h *DumbSchemeHandler) registerDefaults() {
113127 // Error page (static fallback)
@@ -137,6 +151,9 @@ func (h *DumbSchemeHandler) registerDefaults() {
137151 if req.Method != "" && req.Method != httpGET {
138152 return nil
139153 }
154+ if !isTrustedSystemviewAPIRequest(req) {
155+ return privateJSONErrorResponse(http.StatusForbidden, "forbidden")
156+ }
140157
141158 h.mu.RLock()
142159 build := h.currentConfigPayload
@@ -149,12 +166,22 @@ func (h *DumbSchemeHandler) registerDefaults() {
149166 if req.Method != "" && req.Method != httpGET {
150167 return nil
151168 }
169+ if !isTrustedSystemviewAPIRequest(req) {
170+ return privateJSONErrorResponse(http.StatusForbidden, "forbidden")
171+ }
152172
153173 h.mu.RLock()
154174 build := h.defaultConfigPayload
155175 h.mu.RUnlock()
156176 return buildConfigResponse(build)
157177 }))
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+ }))
158185 }
159186
160187 func crashOriginalURI(requestURI string) string {
@@ -176,28 +203,129 @@ func buildCrashPageHTML(originalURI string) string {
176203 return webutil.BuildCrashPageHTML(originalURI)
177204 }
178205
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+
179311 func buildConfigResponse(build func() ([]byte, error)) *SchemeResponse {
180312 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
186315 }
187316
188317 data, err := build()
189318 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
195321 }
196322
197323 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,
201329 }
202330 }
203331
@@ -217,6 +345,7 @@ func (h *DumbSchemeHandler) HandleRequest(reqPtr uintptr) {
217345 }
218346
219347 uri := req.GetUri()
348+ requestHeaders := req.GetHttpHeaders()
220349 schemeReq := &SchemeRequest{
221350 inner: req,
222351 URI: uri,
@@ -224,6 +353,10 @@ func (h *DumbSchemeHandler) HandleRequest(reqPtr uintptr) {
224353 Method: req.GetHttpMethod(),
225354 Scheme: req.GetScheme(),
226355 }
356+ if requestHeaders != nil {
357+ schemeReq.Origin = strings.TrimSpace(requestHeaders.GetOne("Origin"))
358+ schemeReq.Referer = strings.TrimSpace(requestHeaders.GetOne("Referer"))
359+ }
227360
228361 h.logger.Debug().
229362 Str("uri", schemeReq.URI).
@@ -412,7 +545,7 @@ func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
412545
413546 func shouldAddCORSHeaders(path string) bool {
414547 path = strings.TrimSpace(path)
415- return strings.HasPrefix(path, "/api/") || strings.HasSuffix(path, ".wasm")
548+ return strings.HasSuffix(path, ".wasm")
416549 }
417550
418551 func responseHeadersForPath(path, contentType string) map[string]string {
@@ -463,9 +596,12 @@ func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response
463596 schemeResp.SetContentType(contentType)
464597 schemeResp.SetStatus(uint(response.StatusCode), nil)
465598
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+ }
469605 for name, value := range response.Headers {
470606 if headers == nil {
471607 headers = map[string]string{}
M internal/infrastructure/webkit/scheme_handler_test.go +67 -9
@@ -50,6 +50,41 @@ func TestBuildCrashPageHTMLEscapesScriptBreakoutPayload(t *testing.T) {
5050 assert.Contains(t, body, "&lt;/script&gt;&lt;script&gt;alert(1)&lt;/script&gt;")
5151 }
5252
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+
5388 func TestRegisterDefaultsIncludesCrashHandler(t *testing.T) {
5489 handler := NewDumbSchemeHandler(context.Background())
5590 require.NotNil(t, handler)
@@ -162,10 +197,11 @@ func TestConfigHandlersUseInjectedPayloadBuilders(t *testing.T) {
162197 require.NotNil(t, defaultHandler)
163198
164199 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",
169205 })
170206 require.NotNil(t, currentResp)
171207 assert.Equal(t, http.StatusOK, currentResp.StatusCode)
@@ -173,10 +209,11 @@ func TestConfigHandlersUseInjectedPayloadBuilders(t *testing.T) {
173209 assert.Equal(t, []byte(`{"current":true}`), currentResp.Data)
174210
175211 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",
180217 })
181218 require.NotNil(t, defaultResp)
182219 assert.Equal(t, http.StatusOK, defaultResp.StatusCode)
@@ -184,6 +221,27 @@ func TestConfigHandlersUseInjectedPayloadBuilders(t *testing.T) {
184221 assert.Equal(t, []byte(`{"default":true}`), defaultResp.Data)
185222 }
186223
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+
187245 func TestShouldAddCORSHeaders(t *testing.T) {
188246 t.Parallel()
189247
@@ -192,7 +250,7 @@ func TestShouldAddCORSHeaders(t *testing.T) {
192250 path string
193251 want bool
194252 }{
195- {name: "api route", path: "/api/config", want: true},
253+ {name: "private api route", path: "/api/config", want: false},
196254 {name: "wasm asset", path: "/systemviews.wasm", want: true},
197255 {name: "html shell", path: "/", want: false},
198256 {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) {
204204 glib.IdleAdd(&cb, 0)
205205 })
206206 }
207+ if faviconSetter, ok := deps.Engine.(port.SystemviewFaviconServiceSetter); ok {
208+ faviconSetter.SetSystemviewFaviconService(deps.FaviconService)
209+ }
210+
207211 // Register message handlers through the engine.
208212 if err := deps.Engine.RegisterHandlers(ctx, port.HandlerDependencies{
209213 HistoryUC: deps.HistoryUC,
M internal/ui/systemviews/app.go +138 -22
@@ -6,6 +6,7 @@ import (
66 "fmt"
77 "strings"
88 "sync"
9+ "time"
910
1011 "github.com/bnema/dumber/internal/application/dto"
1112 "github.com/bnema/dumber/internal/application/port"
@@ -29,11 +30,15 @@ type App struct {
2930 currentRoute Route
3031 shellTheme shellTheme
3132 historyEntries []*entity.HistoryEntry
33+ historyStats *entity.HistoryStats
3234 historyAnalytics *entity.HistoryAnalytics
3335 historyDomainStats []*entity.DomainStat
3436 historyQuery string
3537 historyDomainFilter string
3638 historyOffset int
39+ historyWindowBefore time.Time
40+ historyWindowAfter time.Time
41+ historyHasMore bool
3742 historyNotice string
3843 historyError string
3944 favorites []*entity.Favorite
@@ -56,7 +61,10 @@ type App struct {
5661 actionWG sync.WaitGroup
5762 }
5863
59-const historyTimelineLimit = 25
64+const (
65+ historyTimelineLimit = 0
66+ historySearchLimit = 100
67+)
6068
6169 func (a *App) lockAction() {
6270 // Lock order: actionMu is never acquired while App.mu is held.
@@ -96,7 +104,10 @@ func (a *App) RunWithContext(ctx context.Context) error {
96104 if a.deps.DOM == nil {
97105 return errors.New("DOM not configured")
98106 }
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 {
100111 return err
101112 }
102113
@@ -104,11 +115,39 @@ func (a *App) RunWithContext(ctx context.Context) error {
104115 return err
105116 }
106117 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)
108124 }
109125 return nil
110126 }
111127
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+
112151 func (a *App) bindDOMActions(ctx context.Context, binder DOMActionBinder) error {
113152 var (
114153 created bool
@@ -329,6 +368,28 @@ func (a *App) releaseDOMBindings() {
329368 }
330369 }
331370
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+
332393 func (a *App) LoadInitial(ctx context.Context) error {
333394 if a == nil {
334395 return errors.New("app is nil")
@@ -338,14 +399,20 @@ func (a *App) LoadInitial(ctx context.Context) error {
338399 a.currentRoute = ParseRoute(a.deps.LocationURI)
339400 }
340401
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 {
342409 switch a.currentRoute {
343410 case RouteHistory:
344- err = a.loadHistoryRoute(ctx)
411+ return a.loadHistoryRoute(ctx)
345412 case RouteFavorites:
346- err = a.loadFavoritesRoute(ctx)
413+ return a.loadFavoritesRoute(ctx)
347414 case RouteConfig:
348- err = a.loadConfigRoute(ctx)
415+ return a.loadConfigRoute(ctx)
349416 default:
350417 a.resetRouteState()
351418 a.renderedHTML = renderAppFrame(renderedPage{
@@ -356,10 +423,6 @@ func (a *App) LoadInitial(ctx context.Context) error {
356423 }, a.shellTheme)
357424 return nil
358425 }
359- if err != nil {
360- a.renderRouteError(err)
361- }
362- return nil
363426 }
364427
365428 func (a *App) renderRouteError(err error) {
@@ -402,6 +465,36 @@ func routeSubtitle(route Route) string {
402465 }
403466 }
404467
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+
405498 func (a *App) loadHistoryRoute(ctx context.Context) error {
406499 if a.deps.History == nil {
407500 a.resetRouteState()
@@ -414,13 +507,13 @@ func (a *App) loadHistoryRoute(ctx context.Context) error {
414507 return nil
415508 }
416509
417- entries, err := a.loadHistoryEntries(ctx)
510+ window, entries, err := a.loadHistoryWindow(ctx)
418511 if err != nil {
419512 return err
420513 }
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)
424517 }
425518 domains, domainsErr := a.deps.History.DomainStats(ctx, 10)
426519 if domainsErr != nil {
@@ -430,16 +523,30 @@ func (a *App) loadHistoryRoute(ctx context.Context) error {
430523 a.folders = nil
431524 a.tags = nil
432525 a.historyEntries = entries
433- a.historyAnalytics = analytics
526+ a.historyStats = stats
527+ a.historyAnalytics = nil
434528 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+ }
435538 data := historyRenderData{
436539 Entries: entries,
437- Analytics: analytics,
540+ Stats: stats,
541+ Analytics: nil,
438542 Domains: domains,
439543 Query: a.historyQuery,
440544 DomainFilter: a.historyDomainFilter,
441545 Offset: a.historyOffset,
442546 Limit: historyTimelineLimit,
547+ WindowBefore: a.historyWindowBefore,
548+ WindowAfter: a.historyWindowAfter,
549+ HasMore: a.historyHasMore,
443550 Notice: a.historyNotice,
444551 Error: a.historyError,
445552 }
@@ -452,16 +559,21 @@ func (a *App) loadHistoryRoute(ctx context.Context) error {
452559 return nil
453560 }
454561
455-func (a *App) loadHistoryEntries(ctx context.Context) ([]*entity.HistoryEntry, error) {
562+func (a *App) loadHistoryWindow(ctx context.Context) (*entity.HistoryWindow, []*entity.HistoryEntry, error) {
456563 query := strings.TrimSpace(a.historyQuery)
457564 domain := strings.TrimSpace(a.historyDomainFilter)
458565 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
460572 }
461- if domain != "" {
462- return a.deps.History.TimelineByDomain(ctx, domain, historyTimelineLimit, a.historyOffset)
573+ if window == nil {
574+ return nil, nil, nil
463575 }
464- return a.deps.History.Timeline(ctx, historyTimelineLimit, a.historyOffset)
576+ return window, window.Entries, nil
465577 }
466578
467579 func (a *App) loadFavoritesRoute(ctx context.Context) error {
@@ -571,11 +683,15 @@ func (a *App) loadShellTheme(ctx context.Context) {
571683
572684 func (a *App) resetRouteState() {
573685 a.historyEntries = nil
686+ a.historyStats = nil
574687 a.historyAnalytics = nil
575688 a.historyDomainStats = nil
576689 a.historyQuery = ""
577690 a.historyDomainFilter = ""
578691 a.historyOffset = 0
692+ a.historyWindowBefore = time.Time{}
693+ a.historyWindowAfter = time.Time{}
694+ a.historyHasMore = false
579695 a.historyNotice = ""
580696 a.historyError = ""
581697 a.favorites = nil
M internal/ui/systemviews/app_test.go +206 -9
@@ -3,7 +3,9 @@ package systemviews
33 import (
44 "context"
55 "errors"
6+ "sync"
67 "testing"
8+ "time"
79
810 "github.com/bnema/dumber/internal/application/dto"
911 "github.com/bnema/dumber/internal/application/port"
@@ -78,7 +80,8 @@ func TestAppLoadInitialHistoryRouteUsesStyledSections(t *testing.T) {
7880 assert.Contains(t, app.renderedHTML, "Example")
7981 assert.Contains(t, app.renderedHTML, "https://example.com")
8082 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)
8285 assert.Equal(t, 0, history.offset)
8386
8487 // Shell frame present, no full document wrapper.
@@ -92,6 +95,129 @@ func TestAppLoadInitialHistoryRouteUsesStyledSections(t *testing.T) {
9295 assert.Contains(t, app.renderedHTML, `class="sv-list"`)
9396 }
9497
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+
95221 func TestAppLoadInitialHistoryRouteRendersManagementActions(t *testing.T) {
96222 t.Parallel()
97223
@@ -161,8 +287,8 @@ func TestAppHandleHistoryActionsRefreshesDOM(t *testing.T) {
161287 Action: historyActionFilterDomain,
162288 Data: map[string]string{"domain": "example.com"},
163289 }))
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)
166292 assert.Empty(t, app.historyQuery)
167293
168294 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
@@ -720,16 +846,39 @@ func testDefaultConfigPayload() dto.SystemviewConfigPayload {
720846
721847 // Handwritten fake to capture DOM mounts for stateful render assertions.
722848 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
725854 }
726855
727856 func (d *fakeDOM) Mount(markup string) error {
857+ d.mu.Lock()
728858 d.mounted = true
729859 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+ }
730868 return nil
731869 }
732870
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+
733882 type fakeActionDOM struct {
734883 fakeDOM
735884 handler DOMActionHandler
@@ -741,6 +890,13 @@ func (d *fakeActionDOM) BindActions(handler DOMActionHandler) error {
741890 return nil
742891 }
743892
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+
744900 func (d *fakeActionDOM) Release() {
745901 d.released = true
746902 }
@@ -769,16 +925,32 @@ type fakeHistoryService struct {
769925 searchLimit int
770926 searchEntries []*entity.HistoryEntry
771927
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
776942 }
777943
778944 func (s *fakeHistoryService) Timeline(_ context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
779945 s.called = true
780946 s.limit = limit
781947 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+ }
782954 return s.entries, s.err
783955 }
784956
@@ -790,6 +962,25 @@ func (s *fakeHistoryService) TimelineByDomain(_ context.Context, domain string,
790962 return s.entries, s.err
791963 }
792964
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+
793984 func (s *fakeHistoryService) Search(_ context.Context, query string, limit int) ([]*entity.HistoryEntry, error) {
794985 s.searchCalled = true
795986 s.query = query
@@ -810,7 +1001,13 @@ func (s *fakeHistoryService) DeleteRange(_ context.Context, rangeID string) erro
8101001 return nil
8111002 }
8121003
1004+func (s *fakeHistoryService) Stats(context.Context) (*entity.HistoryStats, error) {
1005+ s.statsCalled = true
1006+ return s.stats, nil
1007+}
1008+
8131009 func (s *fakeHistoryService) Analytics(context.Context) (*entity.HistoryAnalytics, error) {
1010+ s.analyticsCalled = true
8141011 return nil, nil
8151012 }
8161013
M internal/ui/systemviews/components.templ +14 -0
@@ -32,6 +32,20 @@ templ EmptyState(message string) {
3232 <div class="sv-empty"><p>{ message }</p></div>
3333 }
3434
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+
3549 templ Alert(kind string, message string) {
3650 if message != "" {
3751 <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) {
4141 @PaletteFields("Light palette", "light", config.Appearance.LightPalette)
4242 @PaletteFields("Dark palette", "dark", config.Appearance.DarkPalette)
4343 <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>
4646 </div>
4747 </form>
4848 }
@@ -63,7 +63,7 @@ templ PaletteFields(title string, prefix string, palette dto.ColorPalette) {
6363 templ SearchSettings(config dto.SystemviewConfigPayload) {
6464 <form class="sv-card-form" data-sv-action="config.search.save">
6565 <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>
6767 </form>
6868 <div class="sv-group">
6969 <div class="sv-group-header"><h4>Search shortcuts</h4></div>
@@ -72,7 +72,7 @@ templ SearchSettings(config dto.SystemviewConfigPayload) {
7272 <label><span>Key</span><input name="key" type="text" placeholder="ddg" required/></label>
7373 <label><span>URL</span><input name="url" type="text" placeholder="https://duckduckgo.com/?q=%s" pattern=".*%s.*" required/></label>
7474 <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>
7676 </form>
7777 {{ shortcuts := searchShortcutRows(config.SearchShortcuts) }}
7878 if len(shortcuts) == 0 {
@@ -94,8 +94,8 @@ templ SearchShortcutItem(shortcut searchShortcutRow) {
9494 <label><span>Key</span><input name="new_key" type="text" value={ shortcut.Key } required/></label>
9595 <label><span>URL</span><input name="url" type="text" value={ shortcut.URL } pattern=".*%s.*" required/></label>
9696 <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>
9999 </form>
100100 </li>
101101 }
@@ -115,7 +115,7 @@ templ PerformanceSettings(config dto.SystemviewConfigPayload) {
115115 <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>
116116 <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>
117117 <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>
119119 </form>
120120 <div class="sv-stat-grid">
121121 <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) {
138138 @EmptyState("No keybindings configured")
139139 } else {
140140 <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>
142142 </div>
143143 for _, group := range groups {
144144 @ConfigKeybindingGroup(group)
@@ -189,8 +189,8 @@ templ ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding
189189 <input type="hidden" name="mode" value={ group.Mode }/>
190190 <input type="hidden" name="action" value={ binding.Action }/>
191191 <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>
194194 </form>
195195 </div>
196196 </li>
M internal/ui/systemviews/config_templ.go +242 -149 generated file: collapsed
M internal/ui/systemviews/dom.go +6 -0
@@ -24,6 +24,12 @@ type DOMActionBinder interface {
2424 BindActions(handler DOMActionHandler) error
2525 }
2626
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+
2733 func placeholderHTML(route Route) string {
2834 name := html.EscapeString(string(route))
2935 if name == "" || route == RouteUnknown {
M internal/ui/systemviews/dom_js.go +283 -18
@@ -10,9 +10,13 @@ import (
1010 )
1111
1212 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
1620 }
1721
1822 type jsEventBinding struct {
@@ -38,9 +42,64 @@ func (d *browserDOM) Mount(markup string) error {
3842 d.target.Set("innerHTML", markup)
3943 d.updateDocumentTitle()
4044 d.scheduleAlertDismissal()
45+ d.setupHistoryInfiniteScroll()
4146 return nil
4247 }
4348
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+
44103 func (d *browserDOM) updateDocumentTitle() {
45104 if d == nil || !d.document.Truthy() || !d.target.Truthy() {
46105 return
@@ -129,11 +188,11 @@ func (d *browserDOM) BindActions(handler DOMActionHandler) error {
129188 if action == "" {
130189 return nil
131190 }
132- if !confirmAction(element) {
133- return nil
134- }
135191 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+ })
137196 return nil
138197 })
139198 d.addEventBinding(d.target, "click", clickHandler)
@@ -151,15 +210,14 @@ func (d *browserDOM) BindActions(handler DOMActionHandler) error {
151210 if action == "" {
152211 return nil
153212 }
154- if !confirmAction(form) {
155- return nil
156- }
157213 event.Call("preventDefault")
158214 data := collectActionData(form)
159215 for key, value := range collectFormData(form) {
160216 data[key] = value
161217 }
162- handler(DOMAction{Action: action, Data: data})
218+ d.confirmAction(form, func() {
219+ handler(DOMAction{Action: action, Data: data})
220+ })
163221 return nil
164222 })
165223 d.addEventBinding(d.target, "submit", submitHandler)
@@ -206,10 +264,70 @@ func (d *browserDOM) addEventBinding(target js.Value, event string, fn js.Func)
206264 d.bindings = append(d.bindings, jsEventBinding{target: target, event: event, fn: fn})
207265 }
208266
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+
209325 func (d *browserDOM) Release() {
210326 if d == nil {
211327 return
212328 }
329+ d.disconnectHistoryObserver()
330+ d.cleanupActiveConfirmDialog()
213331 for _, binding := range d.bindings {
214332 if binding.target.Truthy() {
215333 binding.target.Call("removeEventListener", binding.event, binding.fn)
@@ -277,16 +395,163 @@ func collectFormData(form js.Value) map[string]string {
277395 return data
278396 }
279397
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())
282408 if message == "" {
283- return true
409+ onConfirm()
410+ return
284411 }
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
288433 }
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
290555 }
291556
292557 func (d *browserDOM) handleKeyboard(event js.Value) {
M internal/ui/systemviews/favorites.templ +10 -10
@@ -63,7 +63,7 @@ templ FavoritesControls(data favoritesRenderData) {
6363 <label><span>Title</span><input name="title" type="text" placeholder="Optional title"/></label>
6464 @FolderSelect("folder_id", data.Folders, nil, "Folder")
6565 @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>
6767 </form>
6868 <div class="sv-filter-panel">
6969 <div class="sv-button-row">
@@ -120,8 +120,8 @@ templ FavoriteItem(favorite *entity.Favorite, folders []*entity.Folder, tags []*
120120 }
121121 </div>
122122 <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>
125125 </div>
126126 </div>
127127 @FavoriteEditForm(favorite, folders)
@@ -137,7 +137,7 @@ templ FavoriteEditForm(favorite *entity.Favorite, folders []*entity.Folder) {
137137 <label><span>Favicon</span><input name="favicon_url" type="url" value={ favorite.FaviconURL }/></label>
138138 @FolderSelect("folder_id", folders, favorite.FolderID, "Folder")
139139 @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>
141141 </form>
142142 }
143143
@@ -160,7 +160,7 @@ templ FolderCreateForm() {
160160 <form class="sv-inline-form" data-sv-action="folder.create">
161161 <label><span>Name</span><input name="name" type="text" required/></label>
162162 <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>
164164 </form>
165165 }
166166
@@ -170,8 +170,8 @@ templ FolderItem(folder *entity.Folder) {
170170 <input type="hidden" name="id" value={ fmt.Sprintf("%d", folder.ID) }/>
171171 <label><span>Name</span><input name="name" type="text" value={ folder.Name } required/></label>
172172 <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>
175175 </form>
176176 </li>
177177 }
@@ -180,7 +180,7 @@ templ TagCreateForm() {
180180 <form class="sv-inline-form" data-sv-action="tag.create">
181181 <label><span>Name</span><input name="name" type="text" required/></label>
182182 <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>
184184 </form>
185185 }
186186
@@ -191,8 +191,8 @@ templ TagItem(tag *entity.Tag) {
191191 <span class="sv-tag-dot" style={ "background:" + safeTagColor(tag.Color) }></span>
192192 <label><span>Name</span><input name="name" type="text" value={ tag.Name } required/></label>
193193 <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>
196196 </form>
197197 </li>
198198 }
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
22
33 import (
44 "fmt"
5+ "strings"
56 "github.com/bnema/dumber/internal/domain/entity"
67 )
78
@@ -28,8 +29,8 @@ templ HistoryControls(data historyRenderData) {
2829 <span>Search</span>
2930 <input data-sv-history-search name="query" type="search" value={ data.Query } placeholder="Search title or URL" autocomplete="off"/>
3031 </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>
3334 </form>
3435 if showHistoryFilters(data) {
3536 <div class="sv-active-filters">
@@ -37,20 +38,18 @@ templ HistoryControls(data historyRenderData) {
3738 <span class="sv-filter-chip">Query: { data.Query }</span>
3839 }
3940 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>
4142 }
4243 </div>
4344 }
4445 <div class="sv-history-actions-panel">
4546 <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>
4948 </div>
5049 <div class="sv-button-row" aria-label="History cleanup">
5150 <span class="sv-meta">Clean up</span>
5251 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>
5453 }
5554 </div>
5655 </div>
@@ -58,20 +57,31 @@ templ HistoryControls(data historyRenderData) {
5857 }
5958
6059 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+ }
6774 }
6875
76+
6977 templ HistoryStatCard(label string, value int64) {
7078 <div class="sv-stat-card"><span class="sv-stat-value">{ fmt.Sprintf("%d", value) }</span><span class="sv-stat-label">{ label }</span></div>
7179 }
7280
7381 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 {
7585 @EmptyState("No domain statistics yet")
7686 } else {
7787 <div class="sv-domain-list">
@@ -80,9 +90,11 @@ templ HistoryDomains(data historyRenderData) {
8090 {{ domainKey := historyDomainActionKey(domain) }}
8191 {{ displayDomain := historyDomainDisplayLabel(domain) }}
8292 <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>
8698 </span>
8799 }
88100 }
@@ -91,36 +103,95 @@ templ HistoryDomains(data historyRenderData) {
91103 }
92104
93105 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…")
97108 } 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)
106141 }
107- </ul>
108- </section>
142+ }
143+ </div>
144+ } else {
145+ @HistoryTimelineGroup(group, true)
109146 }
110147 }
111148 }
112149
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+
113175 templ HistoryItem(entry *entity.HistoryEntry) {
114176 <li class="sv-list-row">
115177 <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>
120191 </div>
121192 <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>
124195 </div>
125196 </article>
126197 </li>
M internal/ui/systemviews/history_actions.go +108 -9
@@ -5,6 +5,7 @@ import (
55 "fmt"
66 "strconv"
77 "strings"
8+ "time"
89 )
910
1011 const (
@@ -13,6 +14,7 @@ const (
1314 historyActionFilterDomain = "history.filterDomain"
1415 historyActionClearDomain = "history.clearDomain"
1516 historyActionPage = "history.page"
17+ historyActionLoadMore = "history.loadMore"
1618 historyActionDeleteEntry = "history.deleteEntry"
1719 historyActionDeleteRange = "history.deleteRange"
1820 historyActionDeleteDomain = "history.deleteDomain"
@@ -26,12 +28,17 @@ func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error {
2628 return fmt.Errorf("app is nil")
2729 }
2830 a.lockState()
29- defer a.unlockState()
3031 if a.currentRoute == RouteUnknown || a.currentRoute == "" {
3132 a.currentRoute = ParseRoute(a.deps.LocationURI)
3233 }
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()
3340
34- switch a.currentRoute {
41+ switch route {
3542 case RouteHistory:
3643 a.historyError = ""
3744 if err := a.handleHistoryAction(ctx, event); err != nil {
@@ -84,12 +91,12 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error {
8491 case historyActionSearch:
8592 a.historyQuery = strings.TrimSpace(data["query"])
8693 a.historyDomainFilter = ""
87- a.historyOffset = 0
94+ a.resetHistoryWindowState()
8895 a.historyNotice = ""
8996 case historyActionClear:
9097 a.historyQuery = ""
9198 a.historyDomainFilter = ""
92- a.historyOffset = 0
99+ a.resetHistoryWindowState()
93100 a.historyNotice = ""
94101 case historyActionFilterDomain:
95102 domain := strings.TrimSpace(data["domain"])
@@ -98,17 +105,18 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error {
98105 }
99106 a.historyDomainFilter = domain
100107 a.historyQuery = ""
101- a.historyOffset = 0
108+ a.resetHistoryWindowState()
102109 a.historyNotice = ""
103110 case historyActionClearDomain:
104111 a.historyDomainFilter = ""
105- a.historyOffset = 0
112+ a.resetHistoryWindowState()
106113 a.historyNotice = ""
107114 case historyActionPage:
108115 offset, err := strconv.Atoi(strings.TrimSpace(data["offset"]))
109116 if err != nil || offset < 0 {
110117 return fmt.Errorf("invalid history offset")
111118 }
119+ a.resetHistoryWindowState()
112120 a.historyOffset = offset
113121 a.historyNotice = ""
114122 case historyActionDeleteEntry:
@@ -119,7 +127,7 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error {
119127 if err := a.deps.History.DeleteEntry(ctx, id); err != nil {
120128 return err
121129 }
122- a.historyOffset = 0
130+ a.resetHistoryWindowState()
123131 a.historyNotice = "Deleted history entry"
124132 case historyActionDeleteRange:
125133 rangeID := strings.TrimSpace(data["range"])
@@ -132,7 +140,7 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error {
132140 if err := a.deps.History.DeleteRange(ctx, rangeID); err != nil {
133141 return err
134142 }
135- a.historyOffset = 0
143+ a.resetHistoryWindowState()
136144 a.historyNotice = historyRangeNotice(rangeID)
137145 case historyActionDeleteDomain:
138146 domain := strings.TrimSpace(data["domain"])
@@ -145,7 +153,7 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error {
145153 if strings.EqualFold(a.historyDomainFilter, domain) {
146154 a.historyDomainFilter = ""
147155 }
148- a.historyOffset = 0
156+ a.resetHistoryWindowState()
149157 a.historyNotice = "Deleted history for " + domain
150158 default:
151159 return fmt.Errorf("unknown history action: %q", event.Action)
@@ -153,6 +161,97 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error {
153161 return nil
154162 }
155163
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+
156255 func (a *App) mountRenderedHTML() error {
157256 return a.mountHTML(a.renderedHTML)
158257 }
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
22
33 import (
44 "fmt"
5+ "net/url"
56 "strings"
67 "time"
78
@@ -9,16 +10,24 @@ import (
910 browserurl "github.com/bnema/dumber/internal/domain/url"
1011 )
1112
13+const historyURLDisplayMaxRunes = 180
14+
1215 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
2231 }
2332
2433 type historyTimelineGroup struct {
@@ -38,7 +47,14 @@ func historyHTML(data historyRenderData) string {
3847 return mustRenderComponent(HistoryView(data))
3948 }
4049
50+func historyTimelineAppendHTML(data historyRenderData) string {
51+ return mustRenderComponent(HistoryTimelineAppend(data))
52+}
53+
4154 func historyDocumentTitle(data historyRenderData) string {
55+ if data.Loading {
56+ return "History — Loading"
57+ }
4258 if query := strings.TrimSpace(data.Query); query != "" {
4359 return "History — search: " + truncateTitle(query, 48)
4460 }
@@ -51,7 +67,9 @@ func historyDocumentTitle(data historyRenderData) string {
5167 }
5268
5369 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 {
5573 entries = data.Analytics.TotalEntries
5674 }
5775 if entries == 1 {
@@ -82,12 +100,19 @@ func historyCleanupItems() []historyCleanupItem {
82100
83101 func historyLimit(data historyRenderData) int {
84102 if data.Limit <= 0 {
85- return historyTimelineLimit
103+ return 0
86104 }
87105 return data.Limit
88106 }
89107
108+func historyIsPaginated(data historyRenderData) bool {
109+ return historyLimit(data) > 0
110+}
111+
90112 func previousHistoryOffset(data historyRenderData) int {
113+ if !historyIsPaginated(data) {
114+ return 0
115+ }
91116 offset := data.Offset - historyLimit(data)
92117 if offset < 0 {
93118 return 0
@@ -96,29 +121,36 @@ func previousHistoryOffset(data historyRenderData) int {
96121 }
97122
98123 func nextHistoryOffset(data historyRenderData) int {
124+ if !historyIsPaginated(data) {
125+ return 0
126+ }
99127 return max(data.Offset, 0) + historyLimit(data)
100128 }
101129
102130 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) != ""
104132 }
105133
106134 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)
108136 }
109137
110138 func showHistoryFilters(data historyRenderData) bool {
111139 return strings.TrimSpace(data.Query) != "" || strings.TrimSpace(data.DomainFilter) != ""
112140 }
113141
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…"
117145 }
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))
120152 }
121- return fmt.Sprintf(" from offset %d", offset)
153+ return fmt.Sprintf("Loaded %d item%s", count, pluralSuffix(count))
122154 }
123155
124156 func pluralSuffix(count int) string {
@@ -128,7 +160,28 @@ func pluralSuffix(count int) string {
128160 return "s"
129161 }
130162
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+
131178 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+ }
132185 entries = int64(countHistoryEntries(data.Entries))
133186 visits = sumHistoryVisits(data.Entries)
134187 uniqueDays = countUniqueHistoryDays(data.Entries)
@@ -137,12 +190,26 @@ func historySummaryValues(data historyRenderData) (entries, visits, uniqueDays i
137190 }
138191
139192 func historyTimelineEmptyMessage(data historyRenderData) string {
193+ if data.Loading {
194+ return "Loading history…"
195+ }
140196 if strings.TrimSpace(data.Query) != "" || strings.TrimSpace(data.DomainFilter) != "" {
141197 return "No history entries match the current filters"
142198 }
143199 return "No history entries"
144200 }
145201
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+
146213 func groupHistoryEntries(entries []*entity.HistoryEntry) []historyTimelineGroup {
147214 groups := make([]historyTimelineGroup, 0)
148215 indexByDate := map[string]int{}
@@ -262,6 +329,60 @@ func historyItemLabel(entry *entity.HistoryEntry) string {
262329 return label
263330 }
264331
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+
265386 func historyItemMeta(entry *entity.HistoryEntry) string {
266387 if entry == nil {
267388 return ""
M internal/ui/systemviews/history_view_test.go +89 -0
@@ -1,9 +1,11 @@
11 package systemviews
22
33 import (
4+ "strings"
45 "testing"
56 "time"
67
8+ "github.com/bnema/dumber/internal/domain/entity"
79 "github.com/stretchr/testify/assert"
810 )
911
@@ -24,3 +26,90 @@ func TestHistoryDateKeyAndLabelUsesProvidedNow(t *testing.T) {
2426 assert.Equal(t, "Yesterday", yesterdayLabel)
2527 assert.Equal(t, "Mon Apr 20", olderLabel)
2628 }
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+}
A internal/ui/systemviews/icons_templ.go +224 -0 generated file: collapsed

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close