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