detached 1708967

refactor(history): refresh sidebar from history changes

commit
170896737016e9a5296a549ee8734d14dd32a264
author
brice · · 2026-06-24 19:24 UTC
committer
brice · · 2026-06-25 04:22 UTC
parent
dc5c960
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
26 files · +3129 -688
M .mockery.yaml +2 -0
@@ -113,6 +113,8 @@ packages:
113113 EntryInputTarget: {}
114114 HomepageFavorites: {}
115115 HomepageHistory: {}
116+ HistoryChangeSink: {}
117+ HistoryMutationCoordinator: {}
116118 HistorySidebarHistory: {}
117119 AllKeybindingsResetter: {}
118120 KeybindingResetter: {}
M cmd/dumber/main.go +47 -30
@@ -259,6 +259,7 @@ func runGUI(cfg *config.Config) int {
259259 engine.SetHandlerContext(ctx)
260260
261261 useCases := createUseCases(repos, cfg)
262+ defer useCases.Close()
262263 if needsEagerDB {
263264 handleAutoRestore(ctx, cfg, useCases, browserSession.Session.ID)
264265 }
@@ -322,6 +323,7 @@ func runStandaloneOmnibox() int {
322323 }
323324
324325 useCases := createUseCases(repos, cfg)
326+ defer useCases.Close()
325327 uiDeps, err := buildUIDependencies(
326328 ctx,
327329 cfg,
@@ -728,21 +730,29 @@ func createLazyRepositories(provider port.DatabaseProvider) *repositories {
728730
729731 // useCases groups application layer use case implementations.
730732 type useCases struct {
731- tabs *usecase.ManageTabsUseCase
732- panes *usecase.ManagePanesUseCase
733- history *usecase.SearchHistoryUseCase
734- favorites *usecase.ManageFavoritesUseCase
735- zoom *usecase.ManageZoomUseCase
736- permission *usecase.HandlePermissionUseCase
737- navigate *usecase.NavigateUseCase
738- copyURL *usecase.CopyURLUseCase
739- snapshot *usecase.SnapshotSessionUseCase
740- lastRestorable *usecase.GetLastRestorableSessionUseCase
741- checkUpdate *usecase.CheckUpdateUseCase
742- applyUpdate *usecase.ApplyUpdateUseCase
743- clipboard port.Clipboard
744- favicon *infrafavicon.Service
745- faviconUC *usecase.FaviconUseCase
733+ tabs *usecase.ManageTabsUseCase
734+ panes *usecase.ManagePanesUseCase
735+ history *usecase.SearchHistoryUseCase
736+ favorites *usecase.ManageFavoritesUseCase
737+ zoom *usecase.ManageZoomUseCase
738+ permission *usecase.HandlePermissionUseCase
739+ navigate *usecase.NavigateUseCase
740+ historyRecorder *usecase.HistoryRecorderUseCase
741+ copyURL *usecase.CopyURLUseCase
742+ snapshot *usecase.SnapshotSessionUseCase
743+ lastRestorable *usecase.GetLastRestorableSessionUseCase
744+ checkUpdate *usecase.CheckUpdateUseCase
745+ applyUpdate *usecase.ApplyUpdateUseCase
746+ clipboard port.Clipboard
747+ favicon *infrafavicon.Service
748+ faviconUC *usecase.FaviconUseCase
749+}
750+
751+func (uc *useCases) Close() {
752+ if uc == nil || uc.historyRecorder == nil {
753+ return
754+ }
755+ uc.historyRecorder.Close()
746756 }
747757
748758 func createUseCases(repos *repositories, cfg *config.Config) *useCases {
@@ -783,23 +793,29 @@ func createUseCases(repos *repositories, cfg *config.Config) *useCases {
783793
784794 // Permission use case will be initialized later with dialog presenter
785795 permissionUC := usecase.NewHandlePermissionUseCase(repos.permission, nil, logging.FromContext)
796+ historyUC := usecase.NewSearchHistoryUseCase(repos.history)
797+ historyRecorderUC := usecase.NewHistoryRecorderUseCase(repos.history, nil)
798+ // App setup passes HistoryRecorderUC into NavigationCoordinatorWithHistoryRecorder
799+ // so LoadCommitted callbacks record history through NavigationCoordinator.RecordHistory.
800+ historyUC.SetHistoryMutationCoordinator(historyRecorderUC)
786801
787802 return &useCases{
788- tabs: usecase.NewManageTabsUseCase(idGenerator),
789- panes: usecase.NewManagePanesUseCase(idGenerator),
790- history: usecase.NewSearchHistoryUseCase(repos.history),
791- favorites: usecase.NewManageFavoritesUseCase(repos.favorite, repos.folder, repos.tag),
792- zoom: usecase.NewManageZoomUseCase(repos.zoom, defaultZoom, zoomCache),
793- permission: permissionUC,
794- navigate: usecase.NewNavigateUseCase(repos.history, repos.zoom, defaultZoom),
795- copyURL: usecase.NewCopyURLUseCase(clipboardAdapter),
796- snapshot: usecase.NewSnapshotSessionUseCase(repos.sessionState),
797- lastRestorable: usecase.NewGetLastRestorableSessionUseCase(repos.session, repos.sessionState),
798- checkUpdate: usecase.NewCheckUpdateUseCase(updateChecker, updateApplier, buildInfo),
799- applyUpdate: usecase.NewApplyUpdateUseCase(updateDownloader, updateApplier, xdgDirs.CacheHome),
800- clipboard: clipboardAdapter,
801- favicon: faviconService,
802- faviconUC: faviconUC,
803+ tabs: usecase.NewManageTabsUseCase(idGenerator),
804+ panes: usecase.NewManagePanesUseCase(idGenerator),
805+ history: historyUC,
806+ favorites: usecase.NewManageFavoritesUseCase(repos.favorite, repos.folder, repos.tag),
807+ zoom: usecase.NewManageZoomUseCase(repos.zoom, defaultZoom, zoomCache),
808+ permission: permissionUC,
809+ navigate: usecase.NewNavigateUseCase(defaultZoom),
810+ historyRecorder: historyRecorderUC,
811+ copyURL: usecase.NewCopyURLUseCase(clipboardAdapter),
812+ snapshot: usecase.NewSnapshotSessionUseCase(repos.sessionState),
813+ lastRestorable: usecase.NewGetLastRestorableSessionUseCase(repos.session, repos.sessionState),
814+ checkUpdate: usecase.NewCheckUpdateUseCase(updateChecker, updateApplier, buildInfo),
815+ applyUpdate: usecase.NewApplyUpdateUseCase(updateDownloader, updateApplier, xdgDirs.CacheHome),
816+ clipboard: clipboardAdapter,
817+ favicon: faviconService,
818+ faviconUC: faviconUC,
803819 }
804820 }
805821
@@ -896,6 +912,7 @@ func buildUIDependencies(
896912 ZoomUC: uc.zoom,
897913 PermissionUC: uc.permission,
898914 NavigateUC: uc.navigate,
915+ HistoryRecorderUC: uc.historyRecorder,
899916 CopyURLUC: uc.copyURL,
900917 Clipboard: uc.clipboard,
901918 FaviconService: legacyFaviconService(uc),
A internal/application/dto/history_change.go +28 -0
@@ -0,0 +1,28 @@
1+package dto
2+
3+import "time"
4+
5+// HistoryChangeReason describes the category of a persisted history mutation.
6+type HistoryChangeReason string
7+
8+const (
9+ // HistoryChangeReasonVisit indicates one or more history visits were persisted.
10+ HistoryChangeReasonVisit HistoryChangeReason = "visit"
11+ // HistoryChangeReasonTitle indicates one or more history titles were updated.
12+ HistoryChangeReasonTitle HistoryChangeReason = "title"
13+ // HistoryChangeReasonDelete indicates one or more history entries were deleted.
14+ HistoryChangeReasonDelete HistoryChangeReason = "delete"
15+ // HistoryChangeReasonClear indicates a history clear operation was persisted.
16+ HistoryChangeReasonClear HistoryChangeReason = "clear"
17+)
18+
19+// HistoryChange reports aggregate facts about persisted history mutations.
20+// It intentionally omits URLs so UI adapters only know that history changed.
21+type HistoryChange struct {
22+ Reasons []HistoryChangeReason
23+ VisitCount int
24+ TitleCount int
25+ DeleteCount int
26+ DeleteCountKnown bool
27+ ChangedAt time.Time
28+}
A internal/application/port/history_changes.go +17 -0
@@ -0,0 +1,17 @@
1+package port
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/dto"
7+)
8+
9+// HistoryChangeSink receives persisted history change notifications.
10+type HistoryChangeSink interface {
11+ OnHistoryChanged(ctx context.Context, change dto.HistoryChange)
12+}
13+
14+// HistoryMutationCoordinator serializes destructive history mutations with pending history writes.
15+type HistoryMutationCoordinator interface {
16+ BeginHistoryMutation(ctx context.Context) (release func(), err error)
17+}
A internal/application/port/mocks/history_changes.go +174 -0
@@ -0,0 +1,174 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "github.com/bnema/dumber/internal/application/dto"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockHistoryChangeSink creates a new instance of MockHistoryChangeSink. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockHistoryChangeSink(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockHistoryChangeSink {
20+ mock := &MockHistoryChangeSink{}
21+ mock.Mock.Test(t)
22+
23+ t.Cleanup(func() { mock.AssertExpectations(t) })
24+
25+ return mock
26+}
27+
28+// MockHistoryChangeSink is an autogenerated mock type for the HistoryChangeSink type
29+type MockHistoryChangeSink struct {
30+ mock.Mock
31+}
32+
33+type MockHistoryChangeSink_Expecter struct {
34+ mock *mock.Mock
35+}
36+
37+func (_m *MockHistoryChangeSink) EXPECT() *MockHistoryChangeSink_Expecter {
38+ return &MockHistoryChangeSink_Expecter{mock: &_m.Mock}
39+}
40+
41+// OnHistoryChanged provides a mock function for the type MockHistoryChangeSink
42+func (_mock *MockHistoryChangeSink) OnHistoryChanged(ctx context.Context, change dto.HistoryChange) {
43+ _mock.Called(ctx, change)
44+ return
45+}
46+
47+// MockHistoryChangeSink_OnHistoryChanged_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnHistoryChanged'
48+type MockHistoryChangeSink_OnHistoryChanged_Call struct {
49+ *mock.Call
50+}
51+
52+// OnHistoryChanged is a helper method to define mock.On call
53+// - ctx context.Context
54+// - change dto.HistoryChange
55+func (_e *MockHistoryChangeSink_Expecter) OnHistoryChanged(ctx any, change any) *MockHistoryChangeSink_OnHistoryChanged_Call {
56+ return &MockHistoryChangeSink_OnHistoryChanged_Call{Call: _e.mock.On("OnHistoryChanged", ctx, change)}
57+}
58+
59+func (_c *MockHistoryChangeSink_OnHistoryChanged_Call) Run(run func(ctx context.Context, change dto.HistoryChange)) *MockHistoryChangeSink_OnHistoryChanged_Call {
60+ _c.Call.Run(func(args mock.Arguments) {
61+ var arg0 context.Context
62+ if args[0] != nil {
63+ arg0 = args[0].(context.Context)
64+ }
65+ var arg1 dto.HistoryChange
66+ if args[1] != nil {
67+ arg1 = args[1].(dto.HistoryChange)
68+ }
69+ run(
70+ arg0,
71+ arg1,
72+ )
73+ })
74+ return _c
75+}
76+
77+func (_c *MockHistoryChangeSink_OnHistoryChanged_Call) Return() *MockHistoryChangeSink_OnHistoryChanged_Call {
78+ _c.Call.Return()
79+ return _c
80+}
81+
82+func (_c *MockHistoryChangeSink_OnHistoryChanged_Call) RunAndReturn(run func(ctx context.Context, change dto.HistoryChange)) *MockHistoryChangeSink_OnHistoryChanged_Call {
83+ _c.Run(run)
84+ return _c
85+}
86+
87+// NewMockHistoryMutationCoordinator creates a new instance of MockHistoryMutationCoordinator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
88+// The first argument is typically a *testing.T value.
89+func NewMockHistoryMutationCoordinator(t interface {
90+ mock.TestingT
91+ Cleanup(func())
92+}) *MockHistoryMutationCoordinator {
93+ mock := &MockHistoryMutationCoordinator{}
94+ mock.Mock.Test(t)
95+
96+ t.Cleanup(func() { mock.AssertExpectations(t) })
97+
98+ return mock
99+}
100+
101+// MockHistoryMutationCoordinator is an autogenerated mock type for the HistoryMutationCoordinator type
102+type MockHistoryMutationCoordinator struct {
103+ mock.Mock
104+}
105+
106+type MockHistoryMutationCoordinator_Expecter struct {
107+ mock *mock.Mock
108+}
109+
110+func (_m *MockHistoryMutationCoordinator) EXPECT() *MockHistoryMutationCoordinator_Expecter {
111+ return &MockHistoryMutationCoordinator_Expecter{mock: &_m.Mock}
112+}
113+
114+// BeginHistoryMutation provides a mock function for the type MockHistoryMutationCoordinator
115+func (_mock *MockHistoryMutationCoordinator) BeginHistoryMutation(ctx context.Context) (func(), error) {
116+ ret := _mock.Called(ctx)
117+
118+ if len(ret) == 0 {
119+ panic("no return value specified for BeginHistoryMutation")
120+ }
121+
122+ var r0 func()
123+ var r1 error
124+ if returnFunc, ok := ret.Get(0).(func(context.Context) (func(), error)); ok {
125+ return returnFunc(ctx)
126+ }
127+ if returnFunc, ok := ret.Get(0).(func(context.Context) func()); ok {
128+ r0 = returnFunc(ctx)
129+ } else {
130+ if ret.Get(0) != nil {
131+ r0 = ret.Get(0).(func())
132+ }
133+ }
134+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
135+ r1 = returnFunc(ctx)
136+ } else {
137+ r1 = ret.Error(1)
138+ }
139+ return r0, r1
140+}
141+
142+// MockHistoryMutationCoordinator_BeginHistoryMutation_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BeginHistoryMutation'
143+type MockHistoryMutationCoordinator_BeginHistoryMutation_Call struct {
144+ *mock.Call
145+}
146+
147+// BeginHistoryMutation is a helper method to define mock.On call
148+// - ctx context.Context
149+func (_e *MockHistoryMutationCoordinator_Expecter) BeginHistoryMutation(ctx any) *MockHistoryMutationCoordinator_BeginHistoryMutation_Call {
150+ return &MockHistoryMutationCoordinator_BeginHistoryMutation_Call{Call: _e.mock.On("BeginHistoryMutation", ctx)}
151+}
152+
153+func (_c *MockHistoryMutationCoordinator_BeginHistoryMutation_Call) Run(run func(ctx context.Context)) *MockHistoryMutationCoordinator_BeginHistoryMutation_Call {
154+ _c.Call.Run(func(args mock.Arguments) {
155+ var arg0 context.Context
156+ if args[0] != nil {
157+ arg0 = args[0].(context.Context)
158+ }
159+ run(
160+ arg0,
161+ )
162+ })
163+ return _c
164+}
165+
166+func (_c *MockHistoryMutationCoordinator_BeginHistoryMutation_Call) Return(release func(), err error) *MockHistoryMutationCoordinator_BeginHistoryMutation_Call {
167+ _c.Call.Return(release, err)
168+ return _c
169+}
170+
171+func (_c *MockHistoryMutationCoordinator_BeginHistoryMutation_Call) RunAndReturn(run func(ctx context.Context) (func(), error)) *MockHistoryMutationCoordinator_BeginHistoryMutation_Call {
172+ _c.Call.Return(run)
173+ return _c
174+}
A internal/application/usecase/history_change_sink.go +37 -0
@@ -0,0 +1,37 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "reflect"
6+
7+ "github.com/bnema/dumber/internal/application/dto"
8+ "github.com/bnema/dumber/internal/application/port"
9+)
10+
11+type noopHistoryChangeSink struct{}
12+
13+func (noopHistoryChangeSink) OnHistoryChanged(_ context.Context, _ dto.HistoryChange) {}
14+
15+func normalizeHistoryChangeSink(sink port.HistoryChangeSink) port.HistoryChangeSink {
16+ if isNilHistoryChangeSink(sink) {
17+ return noopHistoryChangeSink{}
18+ }
19+ return sink
20+}
21+
22+func isNilHistoryChangeSink(sink port.HistoryChangeSink) bool {
23+ return isNilInterface(sink)
24+}
25+
26+func isNilInterface(value any) bool {
27+ if value == nil {
28+ return true
29+ }
30+ reflected := reflect.ValueOf(value)
31+ switch reflected.Kind() {
32+ case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
33+ return reflected.IsNil()
34+ default:
35+ return false
36+ }
37+}
A internal/application/usecase/history_recorder.go +727 -0
@@ -0,0 +1,727 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "errors"
6+ "net"
7+ "net/url"
8+ "strings"
9+ "sync"
10+ "time"
11+
12+ "github.com/bnema/dumber/internal/application/dto"
13+ "github.com/bnema/dumber/internal/application/port"
14+ "github.com/bnema/dumber/internal/domain/entity"
15+ "github.com/bnema/dumber/internal/domain/repository"
16+ "github.com/bnema/dumber/internal/logging"
17+ "github.com/rs/zerolog"
18+)
19+
20+const (
21+ // historyQueueSize is the buffer size for the async history queue.
22+ // If the queue is full, new records are dropped with a warning.
23+ historyQueueSize = 100
24+
25+ // historyWorkerFlushInterval coalesces bursts into fewer persistence writes.
26+ historyWorkerFlushInterval = 100 * time.Millisecond
27+)
28+
29+// historyDeduplicationWindow is the time window for deduplicating history visits.
30+// Visits to the same URL within this window count as a single visit.
31+// This prevents inflation from redirects and rapid navigation.
32+const historyDeduplicationWindow = 2 * time.Second
33+
34+// stripTrackingParamsForHistoryDedup controls whether known tracking parameters
35+// are removed while canonicalizing URLs for history deduplication/storage.
36+const stripTrackingParamsForHistoryDedup = true
37+
38+// historyRecord holds data for async history recording.
39+type historyRecord struct {
40+ url string
41+ title string // non-empty for title-update-only records
42+ visits int
43+}
44+
45+type historyVisitDeltaIncrementer interface {
46+ IncrementVisitCountBy(ctx context.Context, url string, delta int) error
47+}
48+
49+type historyMetadataUpdater interface {
50+ UpdateMetadata(ctx context.Context, entry *entity.HistoryEntry) error
51+}
52+
53+type paneHistoryState struct {
54+ lastRawURL string
55+ lastCanonicalURL string
56+ lastRecordedAt time.Time
57+}
58+
59+type historyCanonicalizationOptions struct {
60+ StripTrackingParams bool
61+}
62+
63+type historyControlRequest struct {
64+ kind historyControlKind
65+ ctx context.Context
66+ done chan error
67+}
68+
69+type historyControlKind int
70+
71+type titlePersistenceResult int
72+
73+const historyControlFlushAndReset historyControlKind = iota
74+
75+const (
76+ titlePersistenceRetry titlePersistenceResult = iota
77+ titlePersistenceSaved
78+ titlePersistenceDiscard
79+)
80+
81+var (
82+ errHistoryRecorderClosing = errors.New("history recorder closing")
83+ errHistoryPersistenceIncomplete = errors.New("history persistence incomplete")
84+)
85+
86+type pendingHistoryRecords struct {
87+ visits map[string]int
88+ titles map[string]string
89+}
90+
91+func newPendingHistoryRecords() *pendingHistoryRecords {
92+ return &pendingHistoryRecords{
93+ visits: make(map[string]int),
94+ titles: make(map[string]string),
95+ }
96+}
97+
98+func (p *pendingHistoryRecords) add(record historyRecord) {
99+ if record.title != "" {
100+ p.titles[record.url] = record.title
101+ }
102+ if record.visits > 0 {
103+ p.visits[record.url] += record.visits
104+ }
105+}
106+
107+func (p *pendingHistoryRecords) empty() bool {
108+ return len(p.visits) == 0 && len(p.titles) == 0
109+}
110+
111+func (p *pendingHistoryRecords) reset() (map[string]int, map[string]string) {
112+ visits := p.visits
113+ titles := p.titles
114+ p.visits = make(map[string]int)
115+ p.titles = make(map[string]string)
116+ return visits, titles
117+}
118+
119+// HistoryRecorderUseCase records persisted history visits and title updates asynchronously.
120+type HistoryRecorderUseCase struct {
121+ historyRepo repository.HistoryRepository
122+ changeSinkMu sync.RWMutex
123+ changeSink port.HistoryChangeSink
124+ recentMu sync.Mutex
125+ recentVisits map[string]paneHistoryState // key: pane ID, value: per-pane history state
126+ mutationMu sync.Mutex
127+ closeOnce sync.Once
128+ enqueueMu sync.Mutex
129+ closing bool
130+
131+ historyQueue chan historyRecord
132+ controlQueue chan historyControlRequest
133+ done chan struct{}
134+ wg sync.WaitGroup
135+ ctx context.Context // Base context for background worker
136+}
137+
138+// NewHistoryRecorderUseCase creates a new history recorder use case.
139+func NewHistoryRecorderUseCase(historyRepo repository.HistoryRepository, changeSink port.HistoryChangeSink) *HistoryRecorderUseCase {
140+ changeSink = normalizeHistoryChangeSink(changeSink)
141+
142+ uc := &HistoryRecorderUseCase{
143+ historyRepo: historyRepo,
144+ changeSink: changeSink,
145+ recentVisits: make(map[string]paneHistoryState),
146+ historyQueue: make(chan historyRecord, historyQueueSize),
147+ controlQueue: make(chan historyControlRequest),
148+ done: make(chan struct{}),
149+ ctx: context.Background(),
150+ }
151+
152+ uc.wg.Add(1)
153+ go uc.historyWorker()
154+
155+ return uc
156+}
157+
158+// SetHistoryChangeSink sets the sink for persisted history change notifications.
159+func (uc *HistoryRecorderUseCase) SetHistoryChangeSink(changeSink port.HistoryChangeSink) {
160+ if uc == nil {
161+ return
162+ }
163+ changeSink = normalizeHistoryChangeSink(changeSink)
164+ uc.changeSinkMu.Lock()
165+ uc.changeSink = changeSink
166+ uc.changeSinkMu.Unlock()
167+}
168+
169+// Close shuts down the background history worker and drains any pending records.
170+func (uc *HistoryRecorderUseCase) Close() {
171+ uc.closeOnce.Do(func() {
172+ uc.enqueueMu.Lock()
173+ uc.closing = true
174+ uc.enqueueMu.Unlock()
175+ close(uc.done)
176+ })
177+ uc.wg.Wait()
178+}
179+
180+// RecordHistory queues a history entry for async recording.
181+// This is non-blocking to avoid SQLite I/O on the GTK main thread.
182+// Should be called on LoadCommitted when URI is guaranteed correct.
183+func (uc *HistoryRecorderUseCase) RecordHistory(ctx context.Context, paneID, rawURL string) {
184+ if uc == nil {
185+ return
186+ }
187+ log := logging.FromContext(ctx)
188+ rawURL = strings.TrimSpace(rawURL)
189+ if rawURL == "" {
190+ return
191+ }
192+
193+ if !isRecordableHistoryURL(rawURL) {
194+ return
195+ }
196+
197+ canonicalURL := canonicalizeURLForHistory(rawURL, historyCanonicalizationOptions{
198+ StripTrackingParams: stripTrackingParamsForHistoryDedup,
199+ })
200+ if canonicalURL == "" {
201+ return
202+ }
203+
204+ paneID = normalizePaneID(paneID)
205+
206+ now := time.Now()
207+
208+ uc.mutationMu.Lock()
209+ defer uc.mutationMu.Unlock()
210+
211+ uc.recentMu.Lock()
212+ state := uc.recentVisits[paneID]
213+
214+ // Ignore in-page hash transitions for history persistence.
215+ if isHashOnlyTransition(state.lastRawURL, rawURL) {
216+ state.lastRawURL = rawURL
217+ uc.recentVisits[paneID] = state
218+ uc.recentMu.Unlock()
219+ return
220+ }
221+
222+ // Per-pane short-window deduplication by canonical URL.
223+ if state.lastCanonicalURL == canonicalURL && now.Sub(state.lastRecordedAt) < historyDeduplicationWindow {
224+ state.lastRawURL = rawURL
225+ uc.recentVisits[paneID] = state
226+ uc.recentMu.Unlock()
227+ return
228+ }
229+
230+ state.lastRawURL = rawURL
231+ state.lastCanonicalURL = canonicalURL
232+ state.lastRecordedAt = now
233+ enqueued := uc.enqueueHistoryRecord(historyRecord{url: canonicalURL, visits: 1})
234+ if enqueued {
235+ uc.recentVisits[paneID] = state
236+ }
237+ uc.recentMu.Unlock()
238+
239+ if !enqueued {
240+ log.Warn().Str("url", logging.RedactURL(canonicalURL)).Msg("history queue full or recorder closed, dropping record")
241+ }
242+}
243+
244+// ClearPaneHistory removes per-pane deduplication state to prevent unbounded growth.
245+// Callers should invoke this when a pane is closed.
246+func (uc *HistoryRecorderUseCase) ClearPaneHistory(paneID string) {
247+ if uc == nil {
248+ return
249+ }
250+ paneID = normalizePaneID(paneID)
251+ uc.recentMu.Lock()
252+ delete(uc.recentVisits, paneID)
253+ uc.recentMu.Unlock()
254+}
255+
256+// UpdateHistoryTitle queues a title update for a history entry. The actual
257+// DB write happens in the background historyWorker to avoid concurrent
258+// repo access from the GTK main thread and the worker goroutine.
259+func (uc *HistoryRecorderUseCase) UpdateHistoryTitle(_ context.Context, historyURL, title string) {
260+ if uc == nil {
261+ return
262+ }
263+ historyURL = strings.TrimSpace(historyURL)
264+ if historyURL == "" || !isRecordableHistoryURL(historyURL) {
265+ return
266+ }
267+ historyURL = canonicalizeURLForHistory(historyURL, historyCanonicalizationOptions{
268+ StripTrackingParams: stripTrackingParamsForHistoryDedup,
269+ })
270+ if historyURL == "" {
271+ return
272+ }
273+
274+ uc.mutationMu.Lock()
275+ defer uc.mutationMu.Unlock()
276+
277+ if !uc.enqueueHistoryRecord(historyRecord{url: historyURL, title: title}) {
278+ logging.FromContext(uc.ctx).Warn().
279+ Str("url", logging.RedactURL(historyURL)).
280+ Msg("history queue full or recorder closed, title update dropped")
281+ }
282+}
283+
284+func (uc *HistoryRecorderUseCase) enqueueHistoryRecord(record historyRecord) bool {
285+ uc.enqueueMu.Lock()
286+ defer uc.enqueueMu.Unlock()
287+ if uc.closing {
288+ return false
289+ }
290+ select {
291+ case uc.historyQueue <- record:
292+ return true
293+ default:
294+ return false
295+ }
296+}
297+
298+// historyWorker is a background goroutine that processes history records.
299+// It drains the queue and persists records to the database without blocking the UI.
300+func (uc *HistoryRecorderUseCase) historyWorker() {
301+ defer uc.wg.Done()
302+
303+ ticker := time.NewTicker(historyWorkerFlushInterval)
304+ defer ticker.Stop()
305+
306+ pending := newPendingHistoryRecords()
307+ for {
308+ select {
309+ case record := <-uc.historyQueue:
310+ pending.add(record)
311+ case <-ticker.C:
312+ _ = uc.flushPendingHistory(uc.ctx, pending)
313+ case req := <-uc.controlQueue:
314+ uc.handleHistoryControl(pending, req)
315+ case <-uc.done:
316+ uc.shutdownHistoryWorker(pending)
317+ return
318+ }
319+ }
320+}
321+
322+func (uc *HistoryRecorderUseCase) flushPendingHistory(ctx context.Context, pending *pendingHistoryRecords) error {
323+ if ctx == nil {
324+ ctx = uc.ctx
325+ }
326+ if pending.empty() {
327+ return nil
328+ }
329+
330+ visitCount := len(pending.visits)
331+ titleCount := len(pending.titles)
332+ logging.FromContext(ctx).Debug().
333+ Int("visits", visitCount).
334+ Int("titles", titleCount).
335+ Msg("flushing pending history")
336+
337+ var flushErr error
338+ successfulVisits := 0
339+ for historyURL, visits := range pending.visits {
340+ if err := ctx.Err(); err != nil {
341+ uc.publishHistoryChange(successfulVisits, 0)
342+ return err
343+ }
344+ record := historyRecord{url: historyURL, visits: visits}
345+ writtenVisits := uc.persistHistory(ctx, record)
346+ if writtenVisits > 0 {
347+ successfulVisits += writtenVisits
348+ if writtenVisits >= visits {
349+ delete(pending.visits, historyURL)
350+ } else {
351+ pending.visits[historyURL] = visits - writtenVisits
352+ }
353+ }
354+ if writtenVisits < visits {
355+ flushErr = errHistoryPersistenceIncomplete
356+ }
357+ }
358+
359+ successfulTitles := 0
360+ for historyURL, title := range pending.titles {
361+ if err := ctx.Err(); err != nil {
362+ uc.publishHistoryChange(successfulVisits, successfulTitles)
363+ return err
364+ }
365+ if _, visitStillPending := pending.visits[historyURL]; visitStillPending {
366+ flushErr = errHistoryPersistenceIncomplete
367+ continue
368+ }
369+ switch uc.persistTitleUpdate(ctx, historyURL, title) {
370+ case titlePersistenceSaved:
371+ successfulTitles++
372+ delete(pending.titles, historyURL)
373+ case titlePersistenceDiscard:
374+ delete(pending.titles, historyURL)
375+ case titlePersistenceRetry:
376+ flushErr = errHistoryPersistenceIncomplete
377+ }
378+ }
379+ uc.publishHistoryChange(successfulVisits, successfulTitles)
380+ if flushErr != nil {
381+ return flushErr
382+ }
383+ return ctx.Err()
384+}
385+
386+func (uc *HistoryRecorderUseCase) handleHistoryControl(pending *pendingHistoryRecords, req historyControlRequest) {
387+ var err error
388+ switch req.kind {
389+ case historyControlFlushAndReset:
390+ uc.drainHistoryQueue(pending)
391+ err = uc.flushPendingHistory(req.ctx, pending)
392+ if err == nil {
393+ uc.resetRecentVisits()
394+ }
395+ }
396+ req.done <- err
397+ close(req.done)
398+}
399+
400+func (uc *HistoryRecorderUseCase) drainHistoryQueue(pending *pendingHistoryRecords) {
401+ for {
402+ select {
403+ case record := <-uc.historyQueue:
404+ pending.add(record)
405+ default:
406+ return
407+ }
408+ }
409+}
410+
411+func (uc *HistoryRecorderUseCase) shutdownHistoryWorker(pending *pendingHistoryRecords) {
412+ log := logging.FromContext(uc.ctx).With().
413+ Str("component", "history-worker").
414+ Logger()
415+
416+ for {
417+ log.Debug().Int("remaining", len(uc.historyQueue)).Msg("draining history queue")
418+ uc.drainHistoryQueue(pending)
419+ uc.flushPendingHistoryForShutdown(log, pending)
420+
421+ uc.enqueueMu.Lock()
422+ uc.drainHistoryQueue(pending)
423+ if pending.empty() && len(uc.historyQueue) == 0 {
424+ uc.closing = true
425+ uc.enqueueMu.Unlock()
426+ log.Debug().Msg("history worker shutdown complete")
427+ return
428+ }
429+ uc.enqueueMu.Unlock()
430+ }
431+}
432+
433+func (uc *HistoryRecorderUseCase) flushPendingHistoryForShutdown(log zerolog.Logger, pending *pendingHistoryRecords) {
434+ const shutdownFlushAttempts = 3
435+ for attempt := 1; attempt <= shutdownFlushAttempts; attempt++ {
436+ err := uc.flushPendingHistory(uc.ctx, pending)
437+ if err == nil || pending.empty() {
438+ return
439+ }
440+ if attempt == shutdownFlushAttempts {
441+ log.Warn().Err(err).Msg("dropping unflushed history during shutdown after retries")
442+ pending.reset()
443+ return
444+ }
445+ log.Warn().Err(err).Int("attempt", attempt).Msg("retrying unflushed history during shutdown")
446+ time.Sleep(historyWorkerFlushInterval)
447+ }
448+}
449+
450+// BeginHistoryMutation flushes pending recorder writes and blocks new recorder enqueues until release is called.
451+func (uc *HistoryRecorderUseCase) BeginHistoryMutation(ctx context.Context) (func(), error) {
452+ if uc == nil {
453+ return func() {}, nil
454+ }
455+ if ctx == nil {
456+ ctx = context.Background()
457+ }
458+ uc.mutationMu.Lock()
459+ if err := uc.flushAndResetHistory(ctx); err != nil {
460+ uc.mutationMu.Unlock()
461+ if errors.Is(err, errHistoryRecorderClosing) {
462+ uc.wg.Wait()
463+ uc.resetRecentVisits()
464+ return func() {}, nil
465+ }
466+ return nil, err
467+ }
468+ return func() { uc.mutationMu.Unlock() }, nil
469+}
470+
471+func (uc *HistoryRecorderUseCase) flushAndResetHistory(ctx context.Context) error {
472+ req := historyControlRequest{
473+ kind: historyControlFlushAndReset,
474+ ctx: ctx,
475+ done: make(chan error, 1),
476+ }
477+ select {
478+ case uc.controlQueue <- req:
479+ select {
480+ case err := <-req.done:
481+ return err
482+ case <-uc.done:
483+ return uc.waitForAcceptedFlushOrShutdown(ctx, req.done)
484+ case <-ctx.Done():
485+ return ctx.Err()
486+ }
487+ case <-uc.done:
488+ return errHistoryRecorderClosing
489+ case <-ctx.Done():
490+ return ctx.Err()
491+ }
492+}
493+
494+func (uc *HistoryRecorderUseCase) waitForAcceptedFlushOrShutdown(ctx context.Context, reqDone <-chan error) error {
495+ wgDone := make(chan struct{})
496+ go func() {
497+ uc.wg.Wait()
498+ close(wgDone)
499+ }()
500+
501+ select {
502+ case err := <-reqDone:
503+ return err
504+ case <-wgDone:
505+ uc.resetRecentVisits()
506+ return errHistoryRecorderClosing
507+ case <-ctx.Done():
508+ return ctx.Err()
509+ }
510+}
511+
512+func (uc *HistoryRecorderUseCase) resetRecentVisits() {
513+ uc.recentMu.Lock()
514+ uc.recentVisits = make(map[string]paneHistoryState)
515+ uc.recentMu.Unlock()
516+}
517+
518+func (uc *HistoryRecorderUseCase) publishHistoryChange(successfulVisits, successfulTitles int) {
519+ if successfulVisits == 0 && successfulTitles == 0 {
520+ return
521+ }
522+ reasons := make([]dto.HistoryChangeReason, 0, 2)
523+ if successfulVisits > 0 {
524+ reasons = append(reasons, dto.HistoryChangeReasonVisit)
525+ }
526+ if successfulTitles > 0 {
527+ reasons = append(reasons, dto.HistoryChangeReasonTitle)
528+ }
529+ sink := uc.historyChangeSink()
530+ sink.OnHistoryChanged(uc.ctx, dto.HistoryChange{
531+ Reasons: reasons,
532+ VisitCount: successfulVisits,
533+ TitleCount: successfulTitles,
534+ ChangedAt: time.Now(),
535+ })
536+}
537+
538+func (uc *HistoryRecorderUseCase) historyChangeSink() port.HistoryChangeSink {
539+ uc.changeSinkMu.RLock()
540+ sink := uc.changeSink
541+ uc.changeSinkMu.RUnlock()
542+ return normalizeHistoryChangeSink(sink)
543+}
544+
545+// persistHistory writes a history record to the database.
546+// Called from the background worker goroutine.
547+func (uc *HistoryRecorderUseCase) persistHistory(ctx context.Context, record historyRecord) int {
548+ log := logging.FromContext(ctx)
549+
550+ existing, err := uc.historyRepo.FindByURL(ctx, record.url)
551+ if err != nil {
552+ log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Msg("failed to check history")
553+ return 0
554+ }
555+
556+ delta := max(1, record.visits)
557+ if existing != nil {
558+ if deltaWriter, ok := uc.historyRepo.(historyVisitDeltaIncrementer); ok {
559+ if err := deltaWriter.IncrementVisitCountBy(ctx, record.url, delta); err != nil {
560+ log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Int("delta", delta).Msg("failed to increment visit count by delta")
561+ return 0
562+ }
563+ return delta
564+ }
565+ written := 0
566+ for i := 0; i < delta; i++ {
567+ if err := uc.historyRepo.IncrementVisitCount(ctx, record.url); err != nil {
568+ log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Msg("failed to increment visit count")
569+ return written
570+ }
571+ written++
572+ }
573+ return written
574+ }
575+
576+ entry := entity.NewHistoryEntry(record.url, "")
577+ entry.VisitCount = int64(delta)
578+ if err := uc.historyRepo.Save(ctx, entry); err != nil {
579+ log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Msg("failed to save history")
580+ return 0
581+ }
582+ return delta
583+}
584+
585+// persistTitleUpdate writes a title update to the database.
586+// Called from the background worker goroutine.
587+func (uc *HistoryRecorderUseCase) persistTitleUpdate(ctx context.Context, historyURL, title string) titlePersistenceResult {
588+ log := logging.FromContext(ctx)
589+
590+ if title == "" {
591+ return titlePersistenceDiscard
592+ }
593+
594+ entry, err := uc.historyRepo.FindByURL(ctx, historyURL)
595+ if err != nil {
596+ log.Warn().Err(err).Str("url", logging.RedactURL(historyURL)).Msg("failed to find history entry for title update")
597+ return titlePersistenceRetry
598+ }
599+ if entry == nil {
600+ return titlePersistenceDiscard
601+ }
602+
603+ entry.Title = title
604+ if updater, ok := uc.historyRepo.(historyMetadataUpdater); ok {
605+ if err := updater.UpdateMetadata(ctx, entry); err != nil {
606+ log.Warn().Err(err).Str("url", logging.RedactURL(historyURL)).Msg("failed to update history metadata")
607+ return titlePersistenceRetry
608+ }
609+ return titlePersistenceSaved
610+ }
611+ if err := uc.historyRepo.Save(ctx, entry); err != nil {
612+ log.Warn().Err(err).Str("url", logging.RedactURL(historyURL)).Msg("failed to update history title")
613+ return titlePersistenceRetry
614+ }
615+ return titlePersistenceSaved
616+}
617+
618+func isRecordableHistoryURL(raw string) bool {
619+ parsed, err := url.Parse(strings.TrimSpace(raw))
620+ if err != nil {
621+ return false
622+ }
623+ if strings.TrimSpace(parsed.Host) == "" {
624+ return false
625+ }
626+ switch strings.ToLower(parsed.Scheme) {
627+ case "http", "https":
628+ return true
629+ default:
630+ return false
631+ }
632+}
633+
634+func canonicalizeURLForHistory(raw string, opts historyCanonicalizationOptions) string {
635+ raw = strings.TrimSpace(raw)
636+ if raw == "" {
637+ return ""
638+ }
639+
640+ parsed, err := url.Parse(raw)
641+ if err != nil {
642+ return strings.TrimSuffix(raw, "/")
643+ }
644+
645+ parsed.Scheme = strings.ToLower(parsed.Scheme)
646+ parsed.Host = canonicalHistoryHost(parsed)
647+ parsed.User = nil
648+ parsed.Fragment = ""
649+ parsed.Path = normalizePathForHistory(parsed.Path)
650+
651+ query := parsed.Query()
652+ if opts.StripTrackingParams {
653+ for key := range query {
654+ if isTrackingQueryParam(key) {
655+ query.Del(key)
656+ }
657+ }
658+ }
659+ parsed.RawQuery = query.Encode()
660+
661+ return parsed.String()
662+}
663+
664+func canonicalHistoryHost(parsed *url.URL) string {
665+ host := strings.ToLower(parsed.Hostname())
666+ portValue := parsed.Port()
667+ if portValue == "" || (parsed.Scheme == "http" && portValue == "80") || (parsed.Scheme == "https" && portValue == "443") {
668+ if strings.Contains(host, ":") {
669+ return "[" + host + "]"
670+ }
671+ return host
672+ }
673+ return net.JoinHostPort(host, portValue)
674+}
675+
676+func normalizePathForHistory(path string) string {
677+ if path == "/" {
678+ return ""
679+ }
680+ if strings.HasSuffix(path, "/") {
681+ return strings.TrimSuffix(path, "/")
682+ }
683+ return path
684+}
685+
686+func isTrackingQueryParam(key string) bool {
687+ key = strings.ToLower(strings.TrimSpace(key))
688+ if key == "" {
689+ return false
690+ }
691+ if strings.HasPrefix(key, "utm_") {
692+ return true
693+ }
694+ switch key {
695+ case "fbclid", "gclid", "msclkid", "dclid", "yclid", "mc_cid", "mc_eid", "_hsenc", "_hsmi", "igshid":
696+ return true
697+ }
698+ return false
699+}
700+
701+func isHashOnlyTransition(previous, current string) bool {
702+ if previous == "" || current == "" || previous == current {
703+ return false
704+ }
705+
706+ prevParsed, prevErr := url.Parse(previous)
707+ currParsed, currErr := url.Parse(current)
708+ if prevErr != nil || currErr != nil {
709+ return false
710+ }
711+
712+ if prevParsed.Fragment == currParsed.Fragment {
713+ return false
714+ }
715+
716+ opts := historyCanonicalizationOptions{StripTrackingParams: stripTrackingParamsForHistoryDedup}
717+ prevCanonical := canonicalizeURLForHistory(previous, opts)
718+ currCanonical := canonicalizeURLForHistory(current, opts)
719+ return prevCanonical != "" && prevCanonical == currCanonical
720+}
721+
722+func normalizePaneID(paneID string) string {
723+ if strings.TrimSpace(paneID) == "" {
724+ return "__default__"
725+ }
726+ return paneID
727+}
A internal/application/usecase/history_recorder_test.go +578 -0
@@ -0,0 +1,578 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/dumber/internal/application/dto"
11+ "github.com/bnema/dumber/internal/domain/entity"
12+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+type recordingHistoryChangeSink struct {
18+ mu sync.Mutex
19+ changes []dto.HistoryChange
20+}
21+
22+func (s *recordingHistoryChangeSink) OnHistoryChanged(_ context.Context, change dto.HistoryChange) {
23+ s.mu.Lock()
24+ defer s.mu.Unlock()
25+ s.changes = append(s.changes, change)
26+}
27+
28+func (s *recordingHistoryChangeSink) snapshot() []dto.HistoryChange {
29+ s.mu.Lock()
30+ defer s.mu.Unlock()
31+ out := make([]dto.HistoryChange, len(s.changes))
32+ copy(out, s.changes)
33+ return out
34+}
35+
36+func TestNormalizeHistoryChangeSink_TreatsTypedNilAsNoop(t *testing.T) {
37+ var sink *recordingHistoryChangeSink
38+
39+ normalized := normalizeHistoryChangeSink(sink)
40+
41+ require.NotNil(t, normalized)
42+ require.NotPanics(t, func() {
43+ normalized.OnHistoryChanged(context.Background(), dto.HistoryChange{Reasons: []dto.HistoryChangeReason{dto.HistoryChangeReasonVisit}})
44+ })
45+}
46+
47+type metadataUpdatingHistoryRepository struct {
48+ *repomocks.MockHistoryRepository
49+ mu sync.Mutex
50+ entry *entity.HistoryEntry
51+}
52+
53+func (r *metadataUpdatingHistoryRepository) UpdateMetadata(_ context.Context, entry *entity.HistoryEntry) error {
54+ r.mu.Lock()
55+ defer r.mu.Unlock()
56+ clone := *entry
57+ r.entry = &clone
58+ return nil
59+}
60+
61+func (r *metadataUpdatingHistoryRepository) snapshotEntry() *entity.HistoryEntry {
62+ r.mu.Lock()
63+ defer r.mu.Unlock()
64+ if r.entry == nil {
65+ return nil
66+ }
67+ clone := *r.entry
68+ return &clone
69+}
70+
71+func TestCanonicalizeURLForHistory_StripsHashTrackingAndTrailingSlash(t *testing.T) {
72+ got := canonicalizeURLForHistory(
73+ "https://Example.com/path/?utm_source=newsletter&utm_campaign=launch&a=2&b=1#section-1",
74+ historyCanonicalizationOptions{StripTrackingParams: true},
75+ )
76+
77+ require.Equal(t, "https://example.com/path?a=2&b=1", got)
78+}
79+
80+func TestCanonicalizeURLForHistory_OptionallyKeepsTrackingParams(t *testing.T) {
81+ got := canonicalizeURLForHistory(
82+ "https://example.com/path/?utm_source=newsletter&b=1#section-1",
83+ historyCanonicalizationOptions{StripTrackingParams: false},
84+ )
85+
86+ require.Equal(t, "https://example.com/path?b=1&utm_source=newsletter", got)
87+}
88+
89+func TestCanonicalizeURLForHistory_RemovesUserinfo(t *testing.T) {
90+ got := canonicalizeURLForHistory(
91+ "https://user:password@example.com/path#section-1",
92+ historyCanonicalizationOptions{StripTrackingParams: true},
93+ )
94+
95+ require.Equal(t, "https://example.com/path", got)
96+}
97+
98+func TestCanonicalizeURLForHistory_RemovesSchemeDefaultPorts(t *testing.T) {
99+ require.Equal(t, "http://example.com/path", canonicalizeURLForHistory(
100+ "http://Example.com:80/path",
101+ historyCanonicalizationOptions{StripTrackingParams: true},
102+ ))
103+ require.Equal(t, "https://example.com/path", canonicalizeURLForHistory(
104+ "https://Example.com:443/path",
105+ historyCanonicalizationOptions{StripTrackingParams: true},
106+ ))
107+ require.Equal(t, "https://example.com:8443/path", canonicalizeURLForHistory(
108+ "https://Example.com:8443/path",
109+ historyCanonicalizationOptions{StripTrackingParams: true},
110+ ))
111+}
112+
113+func TestHistoryRecorder_RecordHistory_IgnoresNonWebSchemes(t *testing.T) {
114+ ctx := context.Background()
115+ sink := &recordingHistoryChangeSink{}
116+ repo := repomocks.NewMockHistoryRepository(t)
117+
118+ uc := NewHistoryRecorderUseCase(repo, sink)
119+ uc.RecordHistory(ctx, "pane-1", "file:///private/history.txt")
120+ uc.RecordHistory(ctx, "pane-1", "data:text/plain,secret")
121+ uc.RecordHistory(ctx, "pane-1", "dumb://history")
122+ uc.RecordHistory(ctx, "pane-1", "about:blank")
123+ uc.Close()
124+
125+ require.Empty(t, sink.snapshot())
126+}
127+
128+func TestHistoryRecorder_UpdateHistoryTitle_IgnoresNonWebSchemes(t *testing.T) {
129+ ctx := context.Background()
130+ sink := &recordingHistoryChangeSink{}
131+ repo := repomocks.NewMockHistoryRepository(t)
132+
133+ uc := NewHistoryRecorderUseCase(repo, sink)
134+ uc.UpdateHistoryTitle(ctx, "file:///private/history.txt", "Secret")
135+ uc.UpdateHistoryTitle(ctx, "dumb://history", "History")
136+ uc.Close()
137+
138+ require.Empty(t, sink.snapshot())
139+}
140+
141+func TestIsHashOnlyTransition_UsesCanonicalComparison(t *testing.T) {
142+ tests := []struct {
143+ name string
144+ previous string
145+ current string
146+ }{
147+ {
148+ name: "query order and tracking params",
149+ previous: "https://example.com/docs?utm_source=newsletter&b=2&a=1#intro",
150+ current: "https://example.com/docs/?a=1&b=2#api",
151+ },
152+ {
153+ name: "default port",
154+ previous: "https://example.com:443/docs#intro",
155+ current: "https://example.com/docs#api",
156+ },
157+ }
158+
159+ for _, tt := range tests {
160+ t.Run(tt.name, func(t *testing.T) {
161+ require.True(t, isHashOnlyTransition(tt.previous, tt.current))
162+ })
163+ }
164+}
165+
166+func TestHistoryRecorder_RecordHistory_CoalescesVisitsAndPublishesOneChange(t *testing.T) {
167+ ctx := context.Background()
168+ sink := &recordingHistoryChangeSink{}
169+ repo := repomocks.NewMockHistoryRepository(t)
170+
171+ repo.EXPECT().FindByURL(mock.Anything, "https://example.com/article").Return(nil, nil).Once()
172+ repo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(entry *entity.HistoryEntry) bool {
173+ return entry.URL == "https://example.com/article" && entry.VisitCount == 2
174+ })).Return(nil).Once()
175+
176+ uc := NewHistoryRecorderUseCase(repo, sink)
177+ uc.RecordHistory(ctx, "pane-1", "https://example.com/article?utm_source=feed")
178+ uc.RecordHistory(ctx, "pane-2", "https://example.com/article")
179+ uc.Close()
180+
181+ changes := sink.snapshot()
182+ require.Len(t, changes, 1)
183+ require.Equal(t, []dto.HistoryChangeReason{dto.HistoryChangeReasonVisit}, changes[0].Reasons)
184+ require.Equal(t, 2, changes[0].VisitCount)
185+ require.Zero(t, changes[0].TitleCount)
186+}
187+
188+func TestHistoryRecorder_CloseDrainsPendingAndPublishes(t *testing.T) {
189+ ctx := context.Background()
190+ sink := &recordingHistoryChangeSink{}
191+ repo := repomocks.NewMockHistoryRepository(t)
192+
193+ repo.EXPECT().FindByURL(mock.Anything, "https://example.com/close").Return(nil, nil).Once()
194+ repo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(entry *entity.HistoryEntry) bool {
195+ return entry.URL == "https://example.com/close" && entry.VisitCount == 1
196+ })).Return(nil).Once()
197+
198+ uc := NewHistoryRecorderUseCase(repo, sink)
199+ uc.RecordHistory(ctx, "pane-1", "https://example.com/close")
200+ uc.Close()
201+
202+ changes := sink.snapshot()
203+ require.Len(t, changes, 1)
204+ require.Equal(t, 1, changes[0].VisitCount)
205+}
206+
207+func TestHistoryRecorder_FailedPersistencePublishesNoChange(t *testing.T) {
208+ ctx := context.Background()
209+ sink := &recordingHistoryChangeSink{}
210+ repo := repomocks.NewMockHistoryRepository(t)
211+
212+ repo.EXPECT().FindByURL(mock.Anything, "https://example.com/fail").Return(nil, errors.New("db down")).Times(3)
213+
214+ uc := NewHistoryRecorderUseCase(repo, sink)
215+ uc.RecordHistory(ctx, "pane-1", "https://example.com/fail")
216+ uc.Close()
217+
218+ require.Empty(t, sink.snapshot())
219+}
220+
221+func TestHistoryRecorder_RecordHistory_DoesNotUpdateDedupeStateWhenEnqueueFails(t *testing.T) {
222+ ctx := context.Background()
223+ uc := &HistoryRecorderUseCase{
224+ recentVisits: make(map[string]paneHistoryState),
225+ historyQueue: make(chan historyRecord, 1),
226+ }
227+ uc.historyQueue <- historyRecord{url: "https://queued.example", visits: 1}
228+
229+ uc.RecordHistory(ctx, "pane-1", "https://example.com/full")
230+
231+ uc.recentMu.Lock()
232+ _, ok := uc.recentVisits["pane-1"]
233+ uc.recentMu.Unlock()
234+ require.False(t, ok, "failed enqueue must not suppress a later retry through dedupe state")
235+}
236+
237+func TestHistoryRecorder_UpdateHistoryTitle_PublishesTitleOnlyWithoutIncrementingVisits(t *testing.T) {
238+ ctx := context.Background()
239+ sink := &recordingHistoryChangeSink{}
240+ repo := &metadataUpdatingHistoryRepository{
241+ MockHistoryRepository: repomocks.NewMockHistoryRepository(t),
242+ entry: &entity.HistoryEntry{
243+ URL: "https://example.com/article",
244+ Title: "Old",
245+ VisitCount: 7,
246+ },
247+ }
248+ repo.EXPECT().FindByURL(mock.Anything, "https://example.com/article").RunAndReturn(
249+ func(context.Context, string) (*entity.HistoryEntry, error) {
250+ return repo.snapshotEntry(), nil
251+ },
252+ ).Once()
253+
254+ uc := NewHistoryRecorderUseCase(repo, sink)
255+ uc.UpdateHistoryTitle(ctx, "https://example.com/article", "New")
256+ uc.Close()
257+
258+ after := repo.snapshotEntry()
259+ require.NotNil(t, after)
260+ require.Equal(t, "New", after.Title)
261+ require.Equal(t, int64(7), after.VisitCount)
262+
263+ changes := sink.snapshot()
264+ require.Len(t, changes, 1)
265+ require.Equal(t, []dto.HistoryChangeReason{dto.HistoryChangeReasonTitle}, changes[0].Reasons)
266+ require.Zero(t, changes[0].VisitCount)
267+ require.Equal(t, 1, changes[0].TitleCount)
268+}
269+
270+func TestHistoryRecorder_FlushPendingHistoryDiscardsTitleWhenHistoryRowMissing(t *testing.T) {
271+ ctx := context.Background()
272+ sink := &recordingHistoryChangeSink{}
273+ repo := repomocks.NewMockHistoryRepository(t)
274+ const historyURL = "https://example.com/missing-title"
275+
276+ repo.EXPECT().FindByURL(mock.Anything, historyURL).Return(nil, nil).Once()
277+
278+ uc := &HistoryRecorderUseCase{historyRepo: repo, changeSink: sink, ctx: ctx}
279+ pending := newPendingHistoryRecords()
280+ pending.add(historyRecord{url: historyURL, title: "Missing"})
281+
282+ err := uc.flushPendingHistory(ctx, pending)
283+
284+ require.NoError(t, err)
285+ require.Empty(t, pending.visits)
286+ require.Empty(t, pending.titles)
287+ require.Empty(t, sink.snapshot())
288+}
289+
290+func TestHistoryRecorder_RecordHistory_IgnoresHashOnlyTransitionsAndPublishesNoSecondChange(t *testing.T) {
291+ ctx := context.Background()
292+ sink := &recordingHistoryChangeSink{}
293+ repo := repomocks.NewMockHistoryRepository(t)
294+
295+ repo.EXPECT().FindByURL(mock.Anything, "https://example.com/docs").Return(nil, nil).Once()
296+ repo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(entry *entity.HistoryEntry) bool {
297+ return entry.URL == "https://example.com/docs" && entry.VisitCount == 1
298+ })).Return(nil).Once()
299+
300+ uc := NewHistoryRecorderUseCase(repo, sink)
301+ uc.RecordHistory(ctx, "pane-1", "https://example.com/docs#intro")
302+ uc.RecordHistory(ctx, "pane-1", "https://example.com/docs#api")
303+ uc.Close()
304+
305+ changes := sink.snapshot()
306+ require.Len(t, changes, 1)
307+ require.Equal(t, 1, changes[0].VisitCount)
308+}
309+
310+func TestHistoryRecorder_RecordHistory_DedupIsPerPane(t *testing.T) {
311+ ctx := context.Background()
312+ sink := &recordingHistoryChangeSink{}
313+ repo := repomocks.NewMockHistoryRepository(t)
314+
315+ repo.EXPECT().FindByURL(mock.Anything, "https://example.com/dedupe").Return(nil, nil).Once()
316+ repo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(entry *entity.HistoryEntry) bool {
317+ return entry.URL == "https://example.com/dedupe" && entry.VisitCount == 2
318+ })).Return(nil).Once()
319+
320+ uc := NewHistoryRecorderUseCase(repo, sink)
321+ uc.RecordHistory(ctx, "pane-1", "https://example.com/dedupe")
322+ uc.RecordHistory(ctx, "pane-1", "https://example.com/dedupe")
323+ uc.RecordHistory(ctx, "pane-2", "https://example.com/dedupe")
324+ uc.Close()
325+
326+ changes := sink.snapshot()
327+ require.Len(t, changes, 1)
328+ require.Equal(t, 2, changes[0].VisitCount)
329+}
330+
331+func TestHistoryRecorder_FlushPendingHistoryTracksPartialFallbackVisitWrites(t *testing.T) {
332+ ctx := context.Background()
333+ sink := &recordingHistoryChangeSink{}
334+ repo := repomocks.NewMockHistoryRepository(t)
335+ const historyURL = "https://example.com/partial"
336+ increments := 0
337+
338+ repo.EXPECT().FindByURL(mock.Anything, historyURL).Return(&entity.HistoryEntry{URL: historyURL}, nil).Once()
339+ repo.EXPECT().IncrementVisitCount(mock.Anything, historyURL).RunAndReturn(func(context.Context, string) error {
340+ increments++
341+ if increments == 3 {
342+ return errors.New("increment failed")
343+ }
344+ return nil
345+ }).Times(3)
346+
347+ uc := &HistoryRecorderUseCase{historyRepo: repo, changeSink: sink, ctx: ctx}
348+ pending := newPendingHistoryRecords()
349+ pending.add(historyRecord{url: historyURL, visits: 3})
350+
351+ err := uc.flushPendingHistory(ctx, pending)
352+
353+ require.ErrorIs(t, err, errHistoryPersistenceIncomplete)
354+ require.Equal(t, map[string]int{historyURL: 1}, pending.visits)
355+ require.Empty(t, pending.titles)
356+ changes := sink.snapshot()
357+ require.Len(t, changes, 1)
358+ require.Equal(t, 2, changes[0].VisitCount)
359+}
360+
361+func TestHistoryRecorder_HandleHistoryControlKeepsDedupeWhenFlushFails(t *testing.T) {
362+ ctx := context.Background()
363+ repo := repomocks.NewMockHistoryRepository(t)
364+ const historyURL = "https://example.com/fail-reset"
365+
366+ repo.EXPECT().FindByURL(mock.Anything, historyURL).Return(nil, errors.New("db down")).Once()
367+
368+ uc := &HistoryRecorderUseCase{
369+ historyRepo: repo,
370+ changeSink: &recordingHistoryChangeSink{},
371+ recentVisits: map[string]paneHistoryState{
372+ "pane-1": {lastCanonicalURL: historyURL},
373+ },
374+ ctx: ctx,
375+ }
376+ pending := newPendingHistoryRecords()
377+ pending.add(historyRecord{url: historyURL, visits: 1})
378+ req := historyControlRequest{kind: historyControlFlushAndReset, ctx: ctx, done: make(chan error, 1)}
379+
380+ uc.handleHistoryControl(pending, req)
381+
382+ require.ErrorIs(t, <-req.done, errHistoryPersistenceIncomplete)
383+ require.Contains(t, uc.recentVisits, "pane-1")
384+}
385+
386+func TestHistoryRecorder_BeginHistoryMutationFlushesPendingAndResetsDedupe(t *testing.T) {
387+ ctx := context.Background()
388+ sink := &recordingHistoryChangeSink{}
389+ repo := repomocks.NewMockHistoryRepository(t)
390+
391+ repo.EXPECT().FindByURL(mock.Anything, "https://example.com/reset").Return(nil, nil).Twice()
392+ repo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(entry *entity.HistoryEntry) bool {
393+ return entry.URL == "https://example.com/reset" && entry.VisitCount == 1
394+ })).Return(nil).Twice()
395+
396+ uc := NewHistoryRecorderUseCase(repo, sink)
397+ uc.RecordHistory(ctx, "pane-1", "https://example.com/reset")
398+
399+ release, err := uc.BeginHistoryMutation(ctx)
400+ require.NoError(t, err)
401+ release()
402+
403+ uc.RecordHistory(ctx, "pane-1", "https://example.com/reset")
404+ uc.Close()
405+
406+ changes := sink.snapshot()
407+ require.Len(t, changes, 2)
408+ require.Equal(t, 1, changes[0].VisitCount)
409+ require.Equal(t, 1, changes[1].VisitCount)
410+}
411+
412+func TestHistoryRecorder_BeginHistoryMutationUsesCallerContextForFlush(t *testing.T) {
413+ type contextKey string
414+ ctx := context.WithValue(context.Background(), contextKey("mutation"), "flush")
415+ sink := &recordingHistoryChangeSink{}
416+ repo := repomocks.NewMockHistoryRepository(t)
417+
418+ repo.EXPECT().FindByURL(mock.Anything, "https://example.com/context").RunAndReturn(
419+ func(ctx context.Context, _ string) (*entity.HistoryEntry, error) {
420+ require.Equal(t, "flush", ctx.Value(contextKey("mutation")))
421+ return nil, nil
422+ },
423+ ).Once()
424+ repo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(entry *entity.HistoryEntry) bool {
425+ return entry.URL == "https://example.com/context" && entry.VisitCount == 1
426+ })).RunAndReturn(func(ctx context.Context, _ *entity.HistoryEntry) error {
427+ require.Equal(t, "flush", ctx.Value(contextKey("mutation")))
428+ return nil
429+ }).Once()
430+
431+ uc := NewHistoryRecorderUseCase(repo, sink)
432+ uc.RecordHistory(context.Background(), "pane-1", "https://example.com/context")
433+
434+ release, err := uc.BeginHistoryMutation(ctx)
435+ require.NoError(t, err)
436+ release()
437+ uc.Close()
438+}
439+
440+func TestHistoryRecorder_BeginHistoryMutationWaitsForInFlightShutdownDrain(t *testing.T) {
441+ ctx := context.Background()
442+ saveStarted := make(chan struct{})
443+ allowSave := make(chan struct{})
444+ repo := repomocks.NewMockHistoryRepository(t)
445+
446+ repo.EXPECT().FindByURL(mock.Anything, "https://example.com/shutdown").Return(nil, nil).Once()
447+ repo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(entry *entity.HistoryEntry) bool {
448+ return entry.URL == "https://example.com/shutdown" && entry.VisitCount == 1
449+ })).RunAndReturn(func(context.Context, *entity.HistoryEntry) error {
450+ close(saveStarted)
451+ <-allowSave
452+ return nil
453+ }).Once()
454+
455+ uc := NewHistoryRecorderUseCase(repo, nil)
456+ uc.RecordHistory(ctx, "pane-1", "https://example.com/shutdown")
457+
458+ closeDone := make(chan struct{})
459+ go func() {
460+ uc.Close()
461+ close(closeDone)
462+ }()
463+ select {
464+ case <-saveStarted:
465+ case <-time.After(time.Second):
466+ t.Fatal("timed out waiting for history save to start")
467+ }
468+
469+ mutationDone := make(chan error, 1)
470+ beginStarted := make(chan struct{})
471+ go func() {
472+ close(beginStarted)
473+ release, err := uc.BeginHistoryMutation(ctx)
474+ if release != nil {
475+ release()
476+ }
477+ mutationDone <- err
478+ }()
479+ select {
480+ case <-beginStarted:
481+ case <-time.After(time.Second):
482+ t.Fatal("timed out waiting for history mutation to start")
483+ }
484+
485+ select {
486+ case err := <-mutationDone:
487+ t.Fatalf("history mutation returned before shutdown drain finished: %v", err)
488+ case <-time.After(50 * time.Millisecond):
489+ }
490+
491+ close(allowSave)
492+ select {
493+ case err := <-mutationDone:
494+ require.NoError(t, err)
495+ case <-time.After(time.Second):
496+ t.Fatal("timed out waiting for history mutation to finish")
497+ }
498+ select {
499+ case <-closeDone:
500+ case <-time.After(time.Second):
501+ t.Fatal("timed out waiting for history recorder shutdown")
502+ }
503+}
504+
505+func TestHistoryWorker_ReenqueueDuringFinalShutdownFlushIsRejected(t *testing.T) {
506+ assertReenqueueDuringFlush(t, false, "")
507+}
508+
509+func TestHistoryWorker_ReenqueueDuringPeriodicFlushIsPersisted(t *testing.T) {
510+ assertReenqueueDuringFlush(t, true, "Queued title")
511+}
512+
513+func assertReenqueueDuringFlush(t *testing.T, waitForPeriodicFlush bool, expectedTitle string) {
514+ t.Helper()
515+ ctx := context.Background()
516+
517+ var mu sync.Mutex
518+ store := make(map[string]*entity.HistoryEntry)
519+ saved := make(chan string, 4)
520+ var saveOnce sync.Once
521+ var uc *HistoryRecorderUseCase
522+
523+ repo := repomocks.NewMockHistoryRepository(t)
524+
525+ repo.EXPECT().FindByURL(mock.Anything, mock.Anything).RunAndReturn(
526+ func(_ context.Context, url string) (*entity.HistoryEntry, error) {
527+ mu.Lock()
528+ defer mu.Unlock()
529+ e := store[url]
530+ if e == nil {
531+ return nil, nil
532+ }
533+ clone := *e
534+ return &clone, nil
535+ },
536+ ).Maybe()
537+
538+ repo.EXPECT().Save(mock.Anything, mock.Anything).RunAndReturn(
539+ func(_ context.Context, entry *entity.HistoryEntry) error {
540+ clone := *entry
541+ mu.Lock()
542+ store[entry.URL] = &clone
543+ mu.Unlock()
544+
545+ saveOnce.Do(func() {
546+ if entry.URL == "https://example.com/article" {
547+ uc.UpdateHistoryTitle(ctx, entry.URL, "Queued title")
548+ }
549+ })
550+ select {
551+ case saved <- entry.URL:
552+ default:
553+ }
554+ return nil
555+ },
556+ ).Maybe()
557+
558+ uc = NewHistoryRecorderUseCase(repo, nil)
559+ uc.RecordHistory(ctx, "pane-1", "https://example.com/article")
560+ if waitForPeriodicFlush {
561+ require.Eventually(t, func() bool {
562+ select {
563+ case url := <-saved:
564+ return url == "https://example.com/article"
565+ default:
566+ return false
567+ }
568+ }, time.Second, 10*time.Millisecond, "periodic flush should save the initial visit before Close")
569+ }
570+ uc.Close()
571+
572+ mu.Lock()
573+ entry := store["https://example.com/article"]
574+ mu.Unlock()
575+ require.NotNil(t, entry)
576+ require.Equal(t, int64(1), entry.VisitCount)
577+ require.Equal(t, expectedTitle, entry.Title)
578+}
M internal/application/usecase/navigate.go +7 -429
@@ -3,108 +3,26 @@ package usecase
33 import (
44 "context"
55 "fmt"
6- "net/url"
7- "strings"
8- "sync"
9- "time"
6+ "math"
107
118 "github.com/bnema/dumber/internal/application/port"
129 "github.com/bnema/dumber/internal/domain/entity"
13- "github.com/bnema/dumber/internal/domain/repository"
1410 "github.com/bnema/dumber/internal/logging"
1511 )
1612
17-const (
18- // historyQueueSize is the buffer size for the async history queue.
19- // If the queue is full, new records are dropped with a warning.
20- historyQueueSize = 100
21-
22- // logURLMaxLen is the max length for URLs in log messages.
23- logURLMaxLen = 60
24-
25- // historyWorkerFlushInterval coalesces bursts into fewer persistence writes.
26- historyWorkerFlushInterval = 100 * time.Millisecond
27-
28- // historyFallbackIncrementCap bounds DB writes when delta-increment API is unavailable.
29- historyFallbackIncrementCap = 256
30-)
31-
32-// historyRecord holds data for async history recording.
33-type historyRecord struct {
34- url string
35- title string // non-empty for title-update-only records
36- visits int
37-}
38-
39-type historyVisitDeltaIncrementer interface {
40- IncrementVisitCountBy(ctx context.Context, url string, delta int) error
41-}
42-
43-type historyMetadataUpdater interface {
44- UpdateMetadata(ctx context.Context, entry *entity.HistoryEntry) error
45-}
46-
47-type paneHistoryState struct {
48- lastRawURL string
49- lastCanonicalURL string
50- lastRecordedAt time.Time
51-}
52-
53-type historyCanonicalizationOptions struct {
54- StripTrackingParams bool
55-}
56-
57-// NavigateUseCase handles URL navigation with history recording and zoom application.
13+// NavigateUseCase handles URL navigation with zoom application.
5814 type NavigateUseCase struct {
59- historyRepo repository.HistoryRepository
60- zoomRepo repository.ZoomRepository
61- defaultZoom float64
62- recentMu sync.Mutex
63- recentVisits map[string]paneHistoryState // key: pane ID, value: per-pane history state
64- closeOnce sync.Once
65-
66- // Async history recording
67- historyQueue chan historyRecord
68- done chan struct{}
69- wg sync.WaitGroup
70- ctx context.Context // Base context for background worker
15+ defaultZoom float64
7116 }
7217
7318 // NewNavigateUseCase creates a new navigation use case.
7419 // defaultZoom is the zoom level to use when no per-domain zoom is saved (typically from config).
75-func NewNavigateUseCase(
76- historyRepo repository.HistoryRepository,
77- zoomRepo repository.ZoomRepository,
78- defaultZoom float64,
79-) *NavigateUseCase {
80- if defaultZoom <= 0 {
20+func NewNavigateUseCase(defaultZoom float64) *NavigateUseCase {
21+ if defaultZoom <= 0 || math.IsNaN(defaultZoom) || math.IsInf(defaultZoom, 0) {
8122 defaultZoom = entity.ZoomDefault
8223 }
8324
84- uc := &NavigateUseCase{
85- historyRepo: historyRepo,
86- zoomRepo: zoomRepo,
87- defaultZoom: defaultZoom,
88- recentVisits: make(map[string]paneHistoryState),
89- historyQueue: make(chan historyRecord, historyQueueSize),
90- done: make(chan struct{}),
91- ctx: context.Background(),
92- }
93-
94- // Start background history worker
95- uc.wg.Add(1)
96- go uc.historyWorker()
97-
98- return uc
99-}
100-
101-// Close shuts down the background history worker and drains any pending records.
102-// This should be called when the application is shutting down.
103-func (uc *NavigateUseCase) Close() {
104- uc.closeOnce.Do(func() {
105- close(uc.done)
106- })
107- uc.wg.Wait()
25+ return &NavigateUseCase{defaultZoom: defaultZoom}
10826 }
10927
11028 // NavigateInput contains parameters for navigation.
@@ -119,7 +37,7 @@ type NavigateOutput struct {
11937 AppliedZoom float64
12038 }
12139
122-// Execute navigates to a URL and records history.
40+// Execute navigates to a URL.
12341 // Zoom is applied later via LoadCommitted event in ContentCoordinator.
12442 func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*NavigateOutput, error) {
12543 log := logging.FromContext(ctx)
@@ -143,259 +61,6 @@ func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*N
14361 }, nil
14462 }
14563
146-// historyDeduplicationWindow is the time window for deduplicating history visits.
147-// Visits to the same URL within this window count as a single visit.
148-// This prevents inflation from redirects and rapid navigation.
149-const historyDeduplicationWindow = 2 * time.Second
150-
151-// stripTrackingParamsForHistoryDedup controls whether known tracking parameters
152-// are removed while canonicalizing URLs for history deduplication/storage.
153-const stripTrackingParamsForHistoryDedup = true
154-
155-// RecordHistory queues a history entry for async recording.
156-// This is non-blocking to avoid SQLite I/O on the GTK main thread.
157-// Should be called on LoadCommitted when URI is guaranteed correct.
158-func (uc *NavigateUseCase) RecordHistory(ctx context.Context, paneID, rawURL string) {
159- log := logging.FromContext(ctx)
160- rawURL = strings.TrimSpace(rawURL)
161- if rawURL == "" {
162- return
163- }
164-
165- canonicalURL := canonicalizeURLForHistory(rawURL, historyCanonicalizationOptions{
166- StripTrackingParams: stripTrackingParamsForHistoryDedup,
167- })
168- if canonicalURL == "" {
169- return
170- }
171-
172- paneID = normalizePaneID(paneID)
173-
174- now := time.Now()
175-
176- uc.recentMu.Lock()
177- state := uc.recentVisits[paneID]
178-
179- // Ignore in-page hash transitions for history persistence.
180- if isHashOnlyTransition(state.lastRawURL, rawURL) {
181- state.lastRawURL = rawURL
182- uc.recentVisits[paneID] = state
183- uc.recentMu.Unlock()
184- return
185- }
186-
187- // Per-pane short-window deduplication by canonical URL.
188- if state.lastCanonicalURL == canonicalURL && now.Sub(state.lastRecordedAt) < historyDeduplicationWindow {
189- state.lastRawURL = rawURL
190- uc.recentVisits[paneID] = state
191- uc.recentMu.Unlock()
192- return
193- }
194-
195- state.lastRawURL = rawURL
196- state.lastCanonicalURL = canonicalURL
197- state.lastRecordedAt = now
198- uc.recentVisits[paneID] = state
199- uc.recentMu.Unlock()
200-
201- // Non-blocking send to async queue
202- select {
203- case uc.historyQueue <- historyRecord{url: canonicalURL, visits: 1}:
204- default:
205- // Queue full - log warning but don't block navigation
206- log.Warn().Str("url", logging.TruncateURL(canonicalURL, logURLMaxLen)).Msg("history queue full, dropping record")
207- }
208-}
209-
210-// ClearPaneHistory removes per-pane deduplication state to prevent unbounded growth.
211-// Callers should invoke this when a pane is closed.
212-func (uc *NavigateUseCase) ClearPaneHistory(paneID string) {
213- paneID = normalizePaneID(paneID)
214- uc.recentMu.Lock()
215- delete(uc.recentVisits, paneID)
216- uc.recentMu.Unlock()
217-}
218-
219-// historyWorker is a background goroutine that processes history records.
220-// It drains the queue and persists records to the database without blocking the UI.
221-func (uc *NavigateUseCase) historyWorker() {
222- defer uc.wg.Done()
223-
224- log := logging.FromContext(uc.ctx).With().
225- Str("component", "history-worker").
226- Logger()
227-
228- ticker := time.NewTicker(historyWorkerFlushInterval)
229- defer ticker.Stop()
230-
231- pendingVisits := make(map[string]int)
232- pendingTitles := make(map[string]string)
233-
234- processRecord := func(record historyRecord) {
235- if record.title != "" {
236- pendingTitles[record.url] = record.title
237- }
238- if record.visits > 0 {
239- pendingVisits[record.url] += record.visits
240- }
241- }
242-
243- flushPending := func() {
244- visitCount := len(pendingVisits)
245- titleCount := len(pendingTitles)
246- if visitCount == 0 && titleCount == 0 {
247- return
248- }
249-
250- log.Debug().Int("visits", visitCount).Int("titles", titleCount).Msg("flushing pending history")
251-
252- // Swap maps first, then materialize immutable slices before invoking
253- // persistence callbacks. This keeps any re-enqueued records isolated to
254- // the fresh pending maps for the next flush cycle.
255- visits := pendingVisits
256- titles := pendingTitles
257- pendingVisits = make(map[string]int)
258- pendingTitles = make(map[string]string)
259-
260- visitRecords := make([]historyRecord, 0, len(visits))
261- for historyURL, v := range visits {
262- visitRecords = append(visitRecords, historyRecord{url: historyURL, visits: v})
263- }
264- titleUpdates := make([]historyRecord, 0, len(titles))
265- for historyURL, title := range titles {
266- titleUpdates = append(titleUpdates, historyRecord{url: historyURL, title: title})
267- }
268-
269- for _, record := range visitRecords {
270- uc.persistHistory(uc.ctx, record)
271- }
272- for _, record := range titleUpdates {
273- uc.persistTitleUpdate(uc.ctx, record.url, record.title)
274- }
275- }
276-
277- drainQueue := func() {
278- for {
279- select {
280- case record := <-uc.historyQueue:
281- processRecord(record)
282- default:
283- return
284- }
285- }
286- }
287-
288- for {
289- select {
290- case record := <-uc.historyQueue:
291- processRecord(record)
292- case <-ticker.C:
293- flushPending()
294- case <-uc.done:
295- log.Debug().Int("remaining", len(uc.historyQueue)).Msg("draining history queue")
296- drainQueue()
297- flushPending()
298- log.Debug().Msg("history worker shutdown complete")
299- return
300- }
301- }
302-}
303-
304-// persistHistory writes a history record to the database.
305-// Called from the background worker goroutine.
306-func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRecord) {
307- log := logging.FromContext(ctx)
308-
309- // Check if entry exists
310- existing, err := uc.historyRepo.FindByURL(ctx, record.url)
311- if err != nil {
312- log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Msg("failed to check history")
313- return
314- }
315-
316- if existing != nil {
317- delta := max(1, record.visits)
318- if deltaWriter, ok := uc.historyRepo.(historyVisitDeltaIncrementer); ok {
319- if err := deltaWriter.IncrementVisitCountBy(ctx, record.url, delta); err != nil {
320- log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Int("delta", delta).Msg("failed to increment visit count by delta")
321- return
322- }
323- return
324- }
325- iterations := delta
326- if iterations > historyFallbackIncrementCap {
327- log.Warn().
328- Str("url", logging.RedactURL(record.url)).
329- Int("delta", delta).
330- Int("cap", historyFallbackIncrementCap).
331- Msg("visit count fallback increment capped")
332- iterations = historyFallbackIncrementCap
333- }
334- for i := 0; i < iterations; i++ {
335- if err := uc.historyRepo.IncrementVisitCount(ctx, record.url); err != nil {
336- log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Msg("failed to increment visit count")
337- return
338- }
339- }
340- } else {
341- // Create new entry
342- entry := entity.NewHistoryEntry(record.url, "")
343- entry.VisitCount = int64(max(1, record.visits))
344- if err := uc.historyRepo.Save(ctx, entry); err != nil {
345- log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Msg("failed to save history")
346- }
347- }
348-}
349-
350-// persistTitleUpdate writes a title update to the database.
351-// Called from the background worker goroutine.
352-func (uc *NavigateUseCase) persistTitleUpdate(ctx context.Context, historyURL, title string) {
353- log := logging.FromContext(ctx)
354-
355- if title == "" {
356- return
357- }
358-
359- entry, err := uc.historyRepo.FindByURL(ctx, historyURL)
360- if err != nil {
361- log.Warn().Err(err).Str("url", logging.RedactURL(historyURL)).Msg("failed to find history entry for title update")
362- return
363- }
364- if entry == nil {
365- return
366- }
367-
368- entry.Title = title
369- if updater, ok := uc.historyRepo.(historyMetadataUpdater); ok {
370- if err := updater.UpdateMetadata(ctx, entry); err != nil {
371- log.Warn().Err(err).Str("url", logging.RedactURL(historyURL)).Msg("failed to update history metadata")
372- }
373- return
374- }
375- if err := uc.historyRepo.Save(ctx, entry); err != nil {
376- log.Warn().Err(err).Str("url", logging.RedactURL(historyURL)).Msg("failed to update history title")
377- }
378-}
379-
380-// UpdateHistoryTitle queues a title update for a history entry. The actual
381-// DB write happens in the background historyWorker to avoid concurrent
382-// repo access from the GTK main thread and the worker goroutine.
383-func (uc *NavigateUseCase) UpdateHistoryTitle(_ context.Context, historyURL, title string) {
384- historyURL = canonicalizeURLForHistory(historyURL, historyCanonicalizationOptions{
385- StripTrackingParams: stripTrackingParamsForHistoryDedup,
386- })
387- if historyURL == "" {
388- return
389- }
390-
391- // Non-blocking send — same queue as visit records.
392- select {
393- case uc.historyQueue <- historyRecord{url: historyURL, title: title}:
394- default:
395- logging.FromContext(uc.ctx).Warn().Str("url", logging.RedactURL(historyURL)).Msg("history queue full, title update dropped")
396- }
397-}
398-
39964 // Reload reloads the current page.
40065 func (uc *NavigateUseCase) Reload(ctx context.Context, webview port.WebView, bypassCache bool) error {
40166 log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger()
@@ -413,90 +78,3 @@ func (uc *NavigateUseCase) Stop(ctx context.Context, webview port.WebView) error
41378 log.Debug().Msg("stopping page load")
41479 return webview.Stop(ctx)
41580 }
416-
417-func canonicalizeURLForHistory(raw string, opts historyCanonicalizationOptions) string {
418- raw = strings.TrimSpace(raw)
419- if raw == "" {
420- return ""
421- }
422-
423- parsed, err := url.Parse(raw)
424- if err != nil {
425- return strings.TrimSuffix(raw, "/")
426- }
427-
428- parsed.Scheme = strings.ToLower(parsed.Scheme)
429- parsed.Host = strings.ToLower(parsed.Host)
430- parsed.Fragment = ""
431- parsed.Path = normalizePathForHistory(parsed.Path)
432-
433- query := parsed.Query()
434- if opts.StripTrackingParams {
435- for key := range query {
436- if isTrackingQueryParam(key) {
437- query.Del(key)
438- }
439- }
440- }
441- parsed.RawQuery = query.Encode()
442-
443- return parsed.String()
444-}
445-
446-func normalizePathForHistory(path string) string {
447- if path == "/" {
448- return ""
449- }
450- if strings.HasSuffix(path, "/") {
451- return strings.TrimSuffix(path, "/")
452- }
453- return path
454-}
455-
456-func isTrackingQueryParam(key string) bool {
457- key = strings.ToLower(strings.TrimSpace(key))
458- if key == "" {
459- return false
460- }
461- if strings.HasPrefix(key, "utm_") {
462- return true
463- }
464- switch key {
465- case "fbclid", "gclid", "msclkid", "dclid", "yclid", "mc_cid", "mc_eid", "_hsenc", "_hsmi", "igshid":
466- return true
467- }
468- return false
469-}
470-
471-func isHashOnlyTransition(previous, current string) bool {
472- if previous == "" || current == "" || previous == current {
473- return false
474- }
475-
476- prevParsed, prevErr := url.Parse(previous)
477- currParsed, currErr := url.Parse(current)
478- if prevErr != nil || currErr != nil {
479- return false
480- }
481-
482- if !strings.EqualFold(prevParsed.Scheme, currParsed.Scheme) {
483- return false
484- }
485- if !strings.EqualFold(prevParsed.Host, currParsed.Host) {
486- return false
487- }
488- if normalizePathForHistory(prevParsed.Path) != normalizePathForHistory(currParsed.Path) {
489- return false
490- }
491- if prevParsed.RawQuery != currParsed.RawQuery {
492- return false
493- }
494- return prevParsed.Fragment != currParsed.Fragment
495-}
496-
497-func normalizePaneID(paneID string) string {
498- if strings.TrimSpace(paneID) == "" {
499- return "__default__"
500- }
501- return paneID
502-}
M internal/application/usecase/navigate_test.go +81 -132
@@ -2,156 +2,105 @@ package usecase
22
33 import (
44 "context"
5- "path/filepath"
6- "sync"
5+ "errors"
6+ "math"
77 "testing"
8- "time"
98
9+ "github.com/bnema/dumber/internal/application/port"
1010 "github.com/bnema/dumber/internal/domain/entity"
11- repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
12- "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
13- "github.com/stretchr/testify/mock"
1411 "github.com/stretchr/testify/require"
1512 )
1613
17-func TestCanonicalizeURLForHistory_StripsHashTrackingAndTrailingSlash(t *testing.T) {
18- got := canonicalizeURLForHistory(
19- "https://Example.com/path/?utm_source=newsletter&utm_campaign=launch&a=2&b=1#section-1",
20- historyCanonicalizationOptions{StripTrackingParams: true},
21- )
22-
23- require.Equal(t, "https://example.com/path?a=2&b=1", got)
14+type fakeWebView struct {
15+ loaded string
16+ loadErr error
2417 }
2518
26-func TestCanonicalizeURLForHistory_OptionallyKeepsTrackingParams(t *testing.T) {
27- got := canonicalizeURLForHistory(
28- "https://example.com/path/?utm_source=newsletter&b=1#section-1",
29- historyCanonicalizationOptions{StripTrackingParams: false},
30- )
31-
32- require.Equal(t, "https://example.com/path?b=1&utm_source=newsletter", got)
19+func (f *fakeWebView) LoadURI(_ context.Context, uri string) error {
20+ if f.loadErr != nil {
21+ return f.loadErr
22+ }
23+ f.loaded = uri
24+ return nil
3325 }
34-
35-func TestRecordHistory_IgnoresHashOnlyTransitions(t *testing.T) {
36- ctx := context.Background()
37- repo := repomocks.NewMockHistoryRepository(t)
38-
39- repo.EXPECT().FindByURL(mock.Anything, "https://example.com/docs").Return(nil, nil).Once()
40- repo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(entry *entity.HistoryEntry) bool {
41- return entry.URL == "https://example.com/docs" && entry.VisitCount == 1
42- })).Return(nil).Once()
43-
44- uc := NewNavigateUseCase(repo, nil, entity.ZoomDefault)
45- uc.RecordHistory(ctx, "pane-1", "https://example.com/docs#intro")
46- uc.RecordHistory(ctx, "pane-1", "https://example.com/docs#api")
47- uc.Close()
26+func (*fakeWebView) LoadHTML(context.Context, string, string) error { return nil }
27+func (*fakeWebView) Reload(context.Context) error { return nil }
28+func (*fakeWebView) ReloadBypassCache(context.Context) error { return nil }
29+func (*fakeWebView) Stop(context.Context) error { return nil }
30+func (*fakeWebView) GoBack(context.Context) error { return nil }
31+func (*fakeWebView) GoForward(context.Context) error { return nil }
32+func (*fakeWebView) ID() port.WebViewID { return 1 }
33+func (*fakeWebView) State() port.WebViewState { return port.WebViewState{} }
34+func (f *fakeWebView) URI() string { return f.loaded }
35+func (*fakeWebView) Title() string { return "" }
36+func (*fakeWebView) IsLoading() bool { return false }
37+func (*fakeWebView) EstimatedProgress() float64 { return 1 }
38+func (*fakeWebView) CanGoBack() bool { return false }
39+func (*fakeWebView) CanGoForward() bool { return false }
40+func (*fakeWebView) SetZoomLevel(context.Context, float64) error { return nil }
41+func (*fakeWebView) GetZoomLevel() float64 { return 1 }
42+func (*fakeWebView) GetFindController() port.FindController { return nil }
43+func (*fakeWebView) SetCallbacks(*port.WebViewCallbacks) {}
44+func (*fakeWebView) RunJavaScript(context.Context, string) {}
45+func (*fakeWebView) SetBackgroundColor(float64, float64, float64, float64) {
4846 }
49-
50-func TestRecordHistory_DedupIsPerPaneAndCoalescesVisits(t *testing.T) {
47+func (*fakeWebView) ResetBackgroundToDefault() {}
48+func (*fakeWebView) Favicon() port.Texture { return nil }
49+func (*fakeWebView) Generation() uint64 { return 0 }
50+func (*fakeWebView) IsFullscreen() bool { return false }
51+func (*fakeWebView) IsPlayingAudio() bool { return false }
52+func (*fakeWebView) IsDestroyed() bool { return false }
53+func (*fakeWebView) Destroy() {}
54+
55+func TestNavigateUseCase_ExecuteLoadsURLAndReturnsDefaultZoom(t *testing.T) {
5156 ctx := context.Background()
52- repo := repomocks.NewMockHistoryRepository(t)
53-
54- repo.EXPECT().FindByURL(mock.Anything, "https://example.com/article").Return(nil, nil).Once()
55- repo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(entry *entity.HistoryEntry) bool {
56- return entry.URL == "https://example.com/article" && entry.VisitCount == 2
57- })).Return(nil).Once()
57+ wv := &fakeWebView{}
58+ uc := NewNavigateUseCase(entity.ZoomDefault)
5859
59- uc := NewNavigateUseCase(repo, nil, entity.ZoomDefault)
60- uc.RecordHistory(ctx, "pane-1", "https://example.com/article?utm_source=feed")
61- uc.RecordHistory(ctx, "pane-1", "https://example.com/article?utm_source=feed")
62- uc.RecordHistory(ctx, "pane-2", "https://example.com/article")
63- uc.Close()
64-}
60+ out, err := uc.Execute(ctx, NavigateInput{URL: "https://example.com", PaneID: "pane-1", WebView: wv})
6561
66-func TestUpdateHistoryTitle_UsesMetadataUpdateWithoutIncrementingVisits(t *testing.T) {
67- ctx := context.Background()
68- dbPath := filepath.Join(t.TempDir(), "dumber.db")
69- db, err := sqlite.NewConnection(ctx, dbPath)
7062 require.NoError(t, err)
71- t.Cleanup(func() { _ = db.Close() })
72-
73- repo := sqlite.NewHistoryRepository(db)
74- require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
75- URL: "https://example.com/article",
76- Title: "Old",
77- }))
78-
79- before, err := repo.FindByURL(ctx, "https://example.com/article")
80- require.NoError(t, err)
81- require.NotNil(t, before)
82-
83- uc := NewNavigateUseCase(repo, nil, entity.ZoomDefault)
84- uc.UpdateHistoryTitle(ctx, "https://example.com/article", "New")
85- uc.Close()
63+ require.Equal(t, "https://example.com", wv.loaded)
64+ require.InDelta(t, entity.ZoomDefault, out.AppliedZoom, 0.0001)
65+}
8666
87- after, err := repo.FindByURL(ctx, "https://example.com/article")
88- require.NoError(t, err)
89- require.NotNil(t, after)
90- require.Equal(t, "New", after.Title)
91- require.Equal(t, before.VisitCount, after.VisitCount)
67+func TestNavigateUseCase_ExecuteUsesConfiguredOrFallbackZoom(t *testing.T) {
68+ tests := []struct {
69+ name string
70+ configuredZoom float64
71+ wantZoom float64
72+ }{
73+ {name: "zero falls back", configuredZoom: 0, wantZoom: entity.ZoomDefault},
74+ {name: "negative falls back", configuredZoom: -0.5, wantZoom: entity.ZoomDefault},
75+ {name: "NaN falls back", configuredZoom: math.NaN(), wantZoom: entity.ZoomDefault},
76+ {name: "positive infinity falls back", configuredZoom: math.Inf(1), wantZoom: entity.ZoomDefault},
77+ {name: "positive is preserved", configuredZoom: 1.25, wantZoom: 1.25},
78+ }
79+
80+ for _, tt := range tests {
81+ t.Run(tt.name, func(t *testing.T) {
82+ ctx := context.Background()
83+ wv := &fakeWebView{}
84+ uc := NewNavigateUseCase(tt.configuredZoom)
85+
86+ out, err := uc.Execute(ctx, NavigateInput{URL: "https://example.com", PaneID: "pane-1", WebView: wv})
87+
88+ require.NoError(t, err)
89+ require.Equal(t, "https://example.com", wv.loaded)
90+ require.InDelta(t, tt.wantZoom, out.AppliedZoom, 0.0001)
91+ })
92+ }
9293 }
9394
94-func TestHistoryWorker_ReenqueueDuringFlushIsPersistedOnShutdown(t *testing.T) {
95+func TestNavigateUseCase_ExecuteReturnsLoadError(t *testing.T) {
9596 ctx := context.Background()
97+ loadErr := errors.New("load failed")
98+ wv := &fakeWebView{loadErr: loadErr}
99+ uc := NewNavigateUseCase(entity.ZoomDefault)
96100
97- // Stateful store — Save writes here, FindByURL reads from here.
98- var mu sync.Mutex
99- store := make(map[string]*entity.HistoryEntry)
100-
101- // Fires once during the first Save to re-enqueue a title update while
102- // the flush is in progress. This is the exact race that caused the
103- // concurrent map crash (fixed by swapping maps in flushPending).
104- var saveOnce sync.Once
105- var uc *NavigateUseCase
106-
107- repo := repomocks.NewMockHistoryRepository(t)
108-
109- repo.EXPECT().FindByURL(mock.Anything, mock.Anything).RunAndReturn(
110- func(_ context.Context, url string) (*entity.HistoryEntry, error) {
111- mu.Lock()
112- defer mu.Unlock()
113- e := store[url]
114- if e == nil {
115- return nil, nil
116- }
117- clone := *e
118- return &clone, nil
119- },
120- ).Maybe()
121-
122- repo.EXPECT().Save(mock.Anything, mock.Anything).RunAndReturn(
123- func(_ context.Context, entry *entity.HistoryEntry) error {
124- clone := *entry
125- mu.Lock()
126- store[entry.URL] = &clone
127- mu.Unlock()
128-
129- // Re-enqueue a title update during the first Save.
130- saveOnce.Do(func() {
131- if entry.URL == "https://example.com/article" {
132- uc.UpdateHistoryTitle(ctx, entry.URL, "Queued title")
133- }
134- })
135- return nil
136- },
137- ).Maybe()
138-
139- uc = NewNavigateUseCase(repo, nil, entity.ZoomDefault)
140- uc.RecordHistory(ctx, "pane-1", "https://example.com/article")
141-
142- // The history worker flushes on a timer; there is no exported sync
143- // mechanism, so we sleep before shutting down. Using 5× the flush
144- // interval for headroom — this is a known flaky pattern, but
145- // restructuring the worker for deterministic sync is out of scope.
146- // Close() performs a final flush, so items re-enqueued during the
147- // timed flush are still captured on shutdown.
148- time.Sleep(historyWorkerFlushInterval * 5)
149- uc.Close()
101+ out, err := uc.Execute(ctx, NavigateInput{URL: "https://example.com", PaneID: "pane-1", WebView: wv})
150102
151- mu.Lock()
152- entry := store["https://example.com/article"]
153- mu.Unlock()
154- require.NotNil(t, entry)
155- require.Equal(t, int64(1), entry.VisitCount)
156- require.Equal(t, "Queued title", entry.Title)
103+ require.Nil(t, out)
104+ require.ErrorIs(t, err, loadErr)
105+ require.Contains(t, err.Error(), "failed to load URL")
157106 }
M internal/application/usecase/search_history.go +132 -2
@@ -4,6 +4,7 @@ import (
44 "context"
55 "fmt"
66 "strings"
7+ "sync"
78 "time"
89
910 "github.com/bnema/dumber/internal/application/dto"
@@ -34,14 +35,48 @@ const (
3435
3536 // SearchHistoryUseCase handles history search and retrieval operations.
3637 type SearchHistoryUseCase struct {
37- historyRepo repository.HistoryRepository
38+ historyRepo repository.HistoryRepository
39+ changeSinkMu sync.RWMutex
40+ changeSink port.HistoryChangeSink
41+ mutationCoordinatorMu sync.RWMutex
42+ mutationCoordinator port.HistoryMutationCoordinator
3843 }
3944
4045 // NewSearchHistoryUseCase creates a new history search use case.
41-func NewSearchHistoryUseCase(historyRepo repository.HistoryRepository) *SearchHistoryUseCase {
46+func NewSearchHistoryUseCase(historyRepo repository.HistoryRepository, changeSink ...port.HistoryChangeSink) *SearchHistoryUseCase {
47+ sink := port.HistoryChangeSink(nil)
48+ if len(changeSink) > 0 {
49+ sink = changeSink[0]
50+ }
51+
4252 return &SearchHistoryUseCase{
4353 historyRepo: historyRepo,
54+ changeSink: normalizeHistoryChangeSink(sink),
55+ }
56+}
57+
58+// SetHistoryChangeSink sets the sink for persisted history change notifications.
59+func (uc *SearchHistoryUseCase) SetHistoryChangeSink(changeSink port.HistoryChangeSink) {
60+ if uc == nil {
61+ return
4462 }
63+ changeSink = normalizeHistoryChangeSink(changeSink)
64+ uc.changeSinkMu.Lock()
65+ uc.changeSink = changeSink
66+ uc.changeSinkMu.Unlock()
67+}
68+
69+// SetHistoryMutationCoordinator sets the coordinator used before destructive history mutations.
70+func (uc *SearchHistoryUseCase) SetHistoryMutationCoordinator(coordinator port.HistoryMutationCoordinator) {
71+ if uc == nil {
72+ return
73+ }
74+ if isNilInterface(coordinator) {
75+ coordinator = nil
76+ }
77+ uc.mutationCoordinatorMu.Lock()
78+ uc.mutationCoordinator = coordinator
79+ uc.mutationCoordinatorMu.Unlock()
4580 }
4681
4782 // SearchInput is an alias for dto.HistorySearchInput.
@@ -252,10 +287,19 @@ func (uc *SearchHistoryUseCase) ClearOlderThan(ctx context.Context, before time.
252287 log := logging.FromContext(ctx)
253288 log.Debug().Time("before", before).Msg("clearing old history")
254289
290+ release, err := uc.beginHistoryMutation(ctx)
291+ if err != nil {
292+ return fmt.Errorf("failed to prepare history mutation: %w", err)
293+ }
294+ release = onceRelease(release)
295+ defer release()
296+
255297 if err := uc.historyRepo.DeleteOlderThan(ctx, before); err != nil {
256298 return fmt.Errorf("failed to clear history: %w", err)
257299 }
300+ release()
258301
302+ uc.publishHistoryChange(ctx, dto.HistoryChangeReasonClear, 0, false)
259303 log.Info().Time("before", before).Msg("old history cleared")
260304 return nil
261305 }
@@ -272,9 +316,18 @@ func (uc *SearchHistoryUseCase) ClearRange(ctx context.Context, rangeID string)
272316
273317 log := logging.FromContext(ctx)
274318 log.Debug().Time("since", cutoff).Str("range", rangeID).Msg("clearing recent history range")
319+ release, err := uc.beginHistoryMutation(ctx)
320+ if err != nil {
321+ return fmt.Errorf("failed to prepare history mutation: %w", err)
322+ }
323+ release = onceRelease(release)
324+ defer release()
325+
275326 if err := uc.historyRepo.DeleteSince(ctx, cutoff); err != nil {
276327 return fmt.Errorf("failed to clear history range: %w", err)
277328 }
329+ release()
330+ uc.publishHistoryChange(ctx, dto.HistoryChangeReasonClear, 0, false)
278331 log.Info().Time("since", cutoff).Str("range", rangeID).Msg("recent history range cleared")
279332 return nil
280333 }
@@ -284,10 +337,19 @@ func (uc *SearchHistoryUseCase) ClearAll(ctx context.Context) error {
284337 log := logging.FromContext(ctx)
285338 log.Debug().Msg("clearing all history")
286339
340+ release, err := uc.beginHistoryMutation(ctx)
341+ if err != nil {
342+ return fmt.Errorf("failed to prepare history mutation: %w", err)
343+ }
344+ release = onceRelease(release)
345+ defer release()
346+
287347 if err := uc.historyRepo.DeleteAll(ctx); err != nil {
288348 return fmt.Errorf("failed to clear all history: %w", err)
289349 }
350+ release()
290351
352+ uc.publishHistoryChange(ctx, dto.HistoryChangeReasonClear, 0, false)
291353 log.Info().Msg("all history cleared")
292354 return nil
293355 }
@@ -297,10 +359,19 @@ func (uc *SearchHistoryUseCase) Delete(ctx context.Context, id int64) error {
297359 log := logging.FromContext(ctx)
298360 log.Debug().Int64("id", id).Msg("deleting history entry")
299361
362+ release, err := uc.beginHistoryMutation(ctx)
363+ if err != nil {
364+ return fmt.Errorf("failed to prepare history mutation: %w", err)
365+ }
366+ release = onceRelease(release)
367+ defer release()
368+
300369 if err := uc.historyRepo.Delete(ctx, id); err != nil {
301370 return fmt.Errorf("failed to delete history entry: %w", err)
302371 }
372+ release()
303373
374+ uc.publishHistoryChange(ctx, dto.HistoryChangeReasonDelete, 1, true)
304375 log.Debug().Int64("id", id).Msg("history entry deleted")
305376 return nil
306377 }
@@ -315,14 +386,73 @@ func (uc *SearchHistoryUseCase) DeleteByDomain(ctx context.Context, domain strin
315386 log := logging.FromContext(ctx)
316387 log.Debug().Str("domain", domain).Msg("deleting history by domain")
317388
389+ release, err := uc.beginHistoryMutation(ctx)
390+ if err != nil {
391+ return fmt.Errorf("failed to prepare history mutation: %w", err)
392+ }
393+ release = onceRelease(release)
394+ defer release()
395+
318396 if err := uc.historyRepo.DeleteByDomain(ctx, domain); err != nil {
319397 return fmt.Errorf("failed to delete history by domain: %w", err)
320398 }
399+ release()
321400
401+ uc.publishHistoryChange(ctx, dto.HistoryChangeReasonDelete, 0, false)
322402 log.Info().Str("domain", domain).Msg("history deleted for domain")
323403 return nil
324404 }
325405
406+func onceRelease(release func()) func() {
407+ var once sync.Once
408+ return func() {
409+ once.Do(release)
410+ }
411+}
412+
413+func (uc *SearchHistoryUseCase) beginHistoryMutation(ctx context.Context) (func(), error) {
414+ uc.mutationCoordinatorMu.RLock()
415+ coordinator := uc.mutationCoordinator
416+ uc.mutationCoordinatorMu.RUnlock()
417+ if coordinator == nil {
418+ return func() {}, nil
419+ }
420+ release, err := coordinator.BeginHistoryMutation(ctx)
421+ if err != nil {
422+ if release != nil {
423+ release()
424+ }
425+ return nil, err
426+ }
427+ if release == nil {
428+ release = func() {}
429+ }
430+ return release, nil
431+}
432+
433+func (uc *SearchHistoryUseCase) publishHistoryChange(
434+ ctx context.Context,
435+ reason dto.HistoryChangeReason,
436+ deleteCount int,
437+ deleteCountKnown bool,
438+) {
439+ change := dto.HistoryChange{
440+ Reasons: []dto.HistoryChangeReason{reason},
441+ DeleteCount: deleteCount,
442+ DeleteCountKnown: deleteCountKnown,
443+ ChangedAt: time.Now(),
444+ }
445+ sink := uc.historyChangeSink()
446+ sink.OnHistoryChanged(ctx, change)
447+}
448+
449+func (uc *SearchHistoryUseCase) historyChangeSink() port.HistoryChangeSink {
450+ uc.changeSinkMu.RLock()
451+ sink := uc.changeSink
452+ uc.changeSinkMu.RUnlock()
453+ return normalizeHistoryChangeSink(sink)
454+}
455+
326456 // GetDomainStats retrieves per-domain visit statistics.
327457 func (uc *SearchHistoryUseCase) GetDomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error) {
328458 log := logging.FromContext(ctx)
M internal/application/usecase/search_history_test.go +467 -9
@@ -1,10 +1,12 @@
11 package usecase_test
22
33 import (
4+ "context"
45 "errors"
56 "testing"
67 "time"
78
9+ "github.com/bnema/dumber/internal/application/dto"
810 "github.com/bnema/dumber/internal/application/usecase"
911 "github.com/bnema/dumber/internal/domain/entity"
1012 repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
@@ -13,6 +15,50 @@ import (
1315 "github.com/stretchr/testify/require"
1416 )
1517
18+type recordingHistoryChangeSink struct {
19+ changes []dto.HistoryChange
20+ onChange func(dto.HistoryChange)
21+}
22+
23+func (s *recordingHistoryChangeSink) OnHistoryChanged(_ context.Context, change dto.HistoryChange) {
24+ if s.onChange != nil {
25+ s.onChange(change)
26+ }
27+ s.changes = append(s.changes, change)
28+}
29+
30+type recordingHistoryMutationCoordinator struct {
31+ beginCount int
32+ releaseCount int
33+ err error
34+}
35+
36+func (c *recordingHistoryMutationCoordinator) BeginHistoryMutation(context.Context) (func(), error) {
37+ c.beginCount++
38+ if c.err != nil {
39+ return nil, c.err
40+ }
41+ return func() { c.releaseCount++ }, nil
42+}
43+
44+type nilPanicHistoryMutationCoordinator struct {
45+ called bool
46+}
47+
48+func (c *nilPanicHistoryMutationCoordinator) BeginHistoryMutation(context.Context) (func(), error) {
49+ c.called = true
50+ return func() {}, nil
51+}
52+
53+func requirePublishedHistoryChange(t *testing.T, sink *recordingHistoryChangeSink, reason dto.HistoryChangeReason) dto.HistoryChange {
54+ t.Helper()
55+ require.Len(t, sink.changes, 1)
56+ change := sink.changes[0]
57+ assert.Equal(t, []dto.HistoryChangeReason{reason}, change.Reasons)
58+ assert.False(t, change.ChangedAt.IsZero())
59+ return change
60+}
61+
1662 func TestSearchHistoryUseCase_GetRecent_ZeroLimitMeansAll(t *testing.T) {
1763 ctx := testContext()
1864 historyRepo := repomocks.NewMockHistoryRepository(t)
@@ -269,7 +315,388 @@ func TestSearchHistoryUseCase_GetDomainStats_ClampsOversizedLimit(t *testing.T)
269315 assert.Empty(t, result)
270316 }
271317
272-func TestSearchHistoryUseCase_ClearRange_RecentRangeDeletesSinceCutoff(t *testing.T) {
318+func TestSearchHistoryUseCase_Delete_PublishesDeleteChangeAfterSuccess(t *testing.T) {
319+ ctx := testContext()
320+ historyRepo := repomocks.NewMockHistoryRepository(t)
321+ historyRepo.EXPECT().Delete(mock.Anything, int64(42)).Return(nil).Once()
322+ sink := &recordingHistoryChangeSink{}
323+ uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
324+
325+ err := uc.Delete(ctx, 42)
326+
327+ require.NoError(t, err)
328+ change := requirePublishedHistoryChange(t, sink, dto.HistoryChangeReasonDelete)
329+ assert.Equal(t, 1, change.DeleteCount)
330+ assert.True(t, change.DeleteCountKnown)
331+}
332+
333+func TestSearchHistoryUseCase_Delete_DoesNotPublishOnRepositoryError(t *testing.T) {
334+ ctx := testContext()
335+ historyRepo := repomocks.NewMockHistoryRepository(t)
336+ historyRepo.EXPECT().Delete(mock.Anything, int64(42)).Return(errors.New("delete failed")).Once()
337+ sink := &recordingHistoryChangeSink{}
338+ uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
339+
340+ err := uc.Delete(ctx, 42)
341+
342+ require.Error(t, err)
343+ assert.Empty(t, sink.changes)
344+}
345+
346+func TestSearchHistoryUseCase_DeleteByDomain_PublishesDeleteChangeAfterSuccess(t *testing.T) {
347+ ctx := testContext()
348+ historyRepo := repomocks.NewMockHistoryRepository(t)
349+ historyRepo.EXPECT().DeleteByDomain(mock.Anything, "example.com").Return(nil).Once()
350+ sink := &recordingHistoryChangeSink{}
351+ uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
352+
353+ err := uc.DeleteByDomain(ctx, "https://example.com/path")
354+
355+ require.NoError(t, err)
356+ change := requirePublishedHistoryChange(t, sink, dto.HistoryChangeReasonDelete)
357+ assert.Zero(t, change.DeleteCount, "domain delete count is unknown for aggregate deletes")
358+ assert.False(t, change.DeleteCountKnown)
359+}
360+
361+func TestSearchHistoryUseCase_DeleteByDomain_DoesNotPublishOnRepositoryError(t *testing.T) {
362+ ctx := testContext()
363+ historyRepo := repomocks.NewMockHistoryRepository(t)
364+ historyRepo.EXPECT().DeleteByDomain(mock.Anything, "example.com").Return(errors.New("delete domain failed")).Once()
365+ sink := &recordingHistoryChangeSink{}
366+ uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
367+
368+ err := uc.DeleteByDomain(ctx, "example.com")
369+
370+ require.Error(t, err)
371+ assert.Empty(t, sink.changes)
372+}
373+
374+func TestSearchHistoryUseCase_SetHistoryMutationCoordinator_TypedNilClearsCoordinator(t *testing.T) {
375+ ctx := testContext()
376+ historyRepo := repomocks.NewMockHistoryRepository(t)
377+ historyRepo.EXPECT().DeleteAll(mock.Anything).Return(nil).Once()
378+ previous := &recordingHistoryMutationCoordinator{}
379+ var coordinator *nilPanicHistoryMutationCoordinator
380+ sink := &recordingHistoryChangeSink{}
381+ uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
382+ uc.SetHistoryMutationCoordinator(previous)
383+ uc.SetHistoryMutationCoordinator(coordinator)
384+
385+ require.NotPanics(t, func() {
386+ require.NoError(t, uc.ClearAll(ctx))
387+ })
388+ require.Zero(t, previous.beginCount)
389+ requirePublishedHistoryChange(t, sink, dto.HistoryChangeReasonClear)
390+}
391+
392+func TestSearchHistoryUseCase_ClearAll_PublishesClearChangeAfterSuccess(t *testing.T) {
393+ ctx := testContext()
394+ historyRepo := repomocks.NewMockHistoryRepository(t)
395+ historyRepo.EXPECT().DeleteAll(mock.Anything).Return(nil).Once()
396+ sink := &recordingHistoryChangeSink{}
397+ uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
398+
399+ err := uc.ClearAll(ctx)
400+
401+ require.NoError(t, err)
402+ change := requirePublishedHistoryChange(t, sink, dto.HistoryChangeReasonClear)
403+ assert.Zero(t, change.DeleteCount)
404+ assert.False(t, change.DeleteCountKnown)
405+}
406+
407+func TestSearchHistoryUseCase_ClearAll_CoordinatesPendingRecorderWritesBeforeDelete(t *testing.T) {
408+ ctx := testContext()
409+ historyRepo := repomocks.NewMockHistoryRepository(t)
410+ coordinator := &recordingHistoryMutationCoordinator{}
411+ historyRepo.EXPECT().DeleteAll(mock.Anything).RunAndReturn(func(context.Context) error {
412+ require.Equal(t, 1, coordinator.beginCount)
413+ require.Zero(t, coordinator.releaseCount)
414+ return nil
415+ }).Once()
416+ sink := &recordingHistoryChangeSink{onChange: func(dto.HistoryChange) {
417+ require.Equal(t, 1, coordinator.releaseCount, "history change should publish after mutation coordinator release")
418+ }}
419+ uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
420+ uc.SetHistoryMutationCoordinator(coordinator)
421+
422+ err := uc.ClearAll(ctx)
423+
424+ require.NoError(t, err)
425+ require.Equal(t, 1, coordinator.beginCount)
426+ require.Equal(t, 1, coordinator.releaseCount)
427+ requirePublishedHistoryChange(t, sink, dto.HistoryChangeReasonClear)
428+}
429+
430+func TestSearchHistoryUseCase_OtherDestructiveMutationsCoordinateBeforeDeleteAndPublish(t *testing.T) {
431+ ctx := testContext()
432+ before := time.Now().Add(-7 * 24 * time.Hour)
433+
434+ tests := []struct {
435+ name string
436+ expectRepo func(*repomocks.MockHistoryRepository, *recordingHistoryMutationCoordinator)
437+ run func(*usecase.SearchHistoryUseCase) error
438+ reason dto.HistoryChangeReason
439+ deleteCount int
440+ deleteCountKnown bool
441+ }{
442+ {
443+ name: "delete",
444+ expectRepo: func(repo *repomocks.MockHistoryRepository, coordinator *recordingHistoryMutationCoordinator) {
445+ repo.EXPECT().Delete(mock.Anything, int64(42)).RunAndReturn(func(context.Context, int64) error {
446+ require.Equal(t, 1, coordinator.beginCount)
447+ require.Zero(t, coordinator.releaseCount)
448+ return nil
449+ }).Once()
450+ },
451+ run: func(uc *usecase.SearchHistoryUseCase) error {
452+ return uc.Delete(ctx, 42)
453+ },
454+ reason: dto.HistoryChangeReasonDelete,
455+ deleteCount: 1,
456+ deleteCountKnown: true,
457+ },
458+ {
459+ name: "delete by domain",
460+ expectRepo: func(repo *repomocks.MockHistoryRepository, coordinator *recordingHistoryMutationCoordinator) {
461+ repo.EXPECT().DeleteByDomain(mock.Anything, "example.com").RunAndReturn(func(context.Context, string) error {
462+ require.Equal(t, 1, coordinator.beginCount)
463+ require.Zero(t, coordinator.releaseCount)
464+ return nil
465+ }).Once()
466+ },
467+ run: func(uc *usecase.SearchHistoryUseCase) error {
468+ return uc.DeleteByDomain(ctx, "https://example.com/path")
469+ },
470+ reason: dto.HistoryChangeReasonDelete,
471+ },
472+ {
473+ name: "clear older than",
474+ expectRepo: func(repo *repomocks.MockHistoryRepository, coordinator *recordingHistoryMutationCoordinator) {
475+ repo.EXPECT().DeleteOlderThan(mock.Anything, before).RunAndReturn(func(context.Context, time.Time) error {
476+ require.Equal(t, 1, coordinator.beginCount)
477+ require.Zero(t, coordinator.releaseCount)
478+ return nil
479+ }).Once()
480+ },
481+ run: func(uc *usecase.SearchHistoryUseCase) error {
482+ return uc.ClearOlderThan(ctx, before)
483+ },
484+ reason: dto.HistoryChangeReasonClear,
485+ },
486+ {
487+ name: "clear range",
488+ expectRepo: func(repo *repomocks.MockHistoryRepository, coordinator *recordingHistoryMutationCoordinator) {
489+ repo.EXPECT().DeleteSince(mock.Anything, mock.AnythingOfType("time.Time")).RunAndReturn(func(context.Context, time.Time) error {
490+ require.Equal(t, 1, coordinator.beginCount)
491+ require.Zero(t, coordinator.releaseCount)
492+ return nil
493+ }).Once()
494+ },
495+ run: func(uc *usecase.SearchHistoryUseCase) error {
496+ return uc.ClearRange(ctx, "hour")
497+ },
498+ reason: dto.HistoryChangeReasonClear,
499+ },
500+ }
501+
502+ for _, tt := range tests {
503+ t.Run(tt.name, func(t *testing.T) {
504+ historyRepo := repomocks.NewMockHistoryRepository(t)
505+ coordinator := &recordingHistoryMutationCoordinator{}
506+ tt.expectRepo(historyRepo, coordinator)
507+ sink := &recordingHistoryChangeSink{onChange: func(dto.HistoryChange) {
508+ require.Equal(t, 1, coordinator.releaseCount, "history change should publish after mutation coordinator release")
509+ }}
510+ uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
511+ uc.SetHistoryMutationCoordinator(coordinator)
512+
513+ err := tt.run(uc)
514+
515+ require.NoError(t, err)
516+ require.Equal(t, 1, coordinator.beginCount)
517+ require.Equal(t, 1, coordinator.releaseCount)
518+ change := requirePublishedHistoryChange(t, sink, tt.reason)
519+ require.Equal(t, tt.deleteCount, change.DeleteCount)
520+ require.Equal(t, tt.deleteCountKnown, change.DeleteCountKnown)
521+ })
522+ }
523+}
524+
525+func TestSearchHistoryUseCase_DestructiveMutationsReleaseCoordinatorOnRepositoryError(t *testing.T) {
526+ ctx := testContext()
527+ before := time.Now().Add(-7 * 24 * time.Hour)
528+ repoErr := errors.New("repository failed")
529+
530+ tests := []struct {
531+ name string
532+ expectRepo func(*repomocks.MockHistoryRepository, *recordingHistoryMutationCoordinator)
533+ run func(*usecase.SearchHistoryUseCase) error
534+ }{
535+ {
536+ name: "delete",
537+ expectRepo: func(repo *repomocks.MockHistoryRepository, coordinator *recordingHistoryMutationCoordinator) {
538+ repo.EXPECT().Delete(mock.Anything, int64(42)).RunAndReturn(func(context.Context, int64) error {
539+ require.Equal(t, 1, coordinator.beginCount)
540+ require.Zero(t, coordinator.releaseCount)
541+ return repoErr
542+ }).Once()
543+ },
544+ run: func(uc *usecase.SearchHistoryUseCase) error { return uc.Delete(ctx, 42) },
545+ },
546+ {
547+ name: "delete by domain",
548+ expectRepo: func(repo *repomocks.MockHistoryRepository, coordinator *recordingHistoryMutationCoordinator) {
549+ repo.EXPECT().DeleteByDomain(mock.Anything, "example.com").RunAndReturn(func(context.Context, string) error {
550+ require.Equal(t, 1, coordinator.beginCount)
551+ require.Zero(t, coordinator.releaseCount)
552+ return repoErr
553+ }).Once()
554+ },
555+ run: func(uc *usecase.SearchHistoryUseCase) error { return uc.DeleteByDomain(ctx, "example.com") },
556+ },
557+ {
558+ name: "clear older than",
559+ expectRepo: func(repo *repomocks.MockHistoryRepository, coordinator *recordingHistoryMutationCoordinator) {
560+ repo.EXPECT().DeleteOlderThan(mock.Anything, before).RunAndReturn(func(context.Context, time.Time) error {
561+ require.Equal(t, 1, coordinator.beginCount)
562+ require.Zero(t, coordinator.releaseCount)
563+ return repoErr
564+ }).Once()
565+ },
566+ run: func(uc *usecase.SearchHistoryUseCase) error { return uc.ClearOlderThan(ctx, before) },
567+ },
568+ {
569+ name: "clear range",
570+ expectRepo: func(repo *repomocks.MockHistoryRepository, coordinator *recordingHistoryMutationCoordinator) {
571+ repo.EXPECT().DeleteSince(mock.Anything, mock.AnythingOfType("time.Time")).RunAndReturn(func(context.Context, time.Time) error {
572+ require.Equal(t, 1, coordinator.beginCount)
573+ require.Zero(t, coordinator.releaseCount)
574+ return repoErr
575+ }).Once()
576+ },
577+ run: func(uc *usecase.SearchHistoryUseCase) error { return uc.ClearRange(ctx, "hour") },
578+ },
579+ {
580+ name: "clear all",
581+ expectRepo: func(repo *repomocks.MockHistoryRepository, coordinator *recordingHistoryMutationCoordinator) {
582+ repo.EXPECT().DeleteAll(mock.Anything).RunAndReturn(func(context.Context) error {
583+ require.Equal(t, 1, coordinator.beginCount)
584+ require.Zero(t, coordinator.releaseCount)
585+ return repoErr
586+ }).Once()
587+ },
588+ run: func(uc *usecase.SearchHistoryUseCase) error { return uc.ClearAll(ctx) },
589+ },
590+ }
591+
592+ for _, tt := range tests {
593+ t.Run(tt.name, func(t *testing.T) {
594+ historyRepo := repomocks.NewMockHistoryRepository(t)
595+ coordinator := &recordingHistoryMutationCoordinator{}
596+ tt.expectRepo(historyRepo, coordinator)
597+ sink := &recordingHistoryChangeSink{}
598+ uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
599+ uc.SetHistoryMutationCoordinator(coordinator)
600+
601+ err := tt.run(uc)
602+
603+ require.Error(t, err)
604+ require.Equal(t, 1, coordinator.beginCount)
605+ require.Equal(t, 1, coordinator.releaseCount)
606+ assert.Empty(t, sink.changes)
607+ })
608+ }
609+}
610+
611+func TestSearchHistoryUseCase_OtherDestructiveMutationsReturnCoordinatorErrorBeforeDeleting(t *testing.T) {
612+ ctx := testContext()
613+ before := time.Now().Add(-7 * 24 * time.Hour)
614+ tests := []struct {
615+ name string
616+ run func(*usecase.SearchHistoryUseCase) error
617+ }{
618+ {name: "delete", run: func(uc *usecase.SearchHistoryUseCase) error { return uc.Delete(ctx, 42) }},
619+ {name: "delete by domain", run: func(uc *usecase.SearchHistoryUseCase) error { return uc.DeleteByDomain(ctx, "example.com") }},
620+ {name: "clear older than", run: func(uc *usecase.SearchHistoryUseCase) error { return uc.ClearOlderThan(ctx, before) }},
621+ {name: "clear range", run: func(uc *usecase.SearchHistoryUseCase) error { return uc.ClearRange(ctx, "hour") }},
622+ }
623+
624+ for _, tt := range tests {
625+ t.Run(tt.name, func(t *testing.T) {
626+ historyRepo := repomocks.NewMockHistoryRepository(t)
627+ coordinator := &recordingHistoryMutationCoordinator{err: errors.New("flush failed")}
628+ sink := &recordingHistoryChangeSink{}
629+ uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
630+ uc.SetHistoryMutationCoordinator(coordinator)
631+
632+ err := tt.run(uc)
633+
634+ require.Error(t, err)
635+ require.Equal(t, 1, coordinator.beginCount)
636+ require.Zero(t, coordinator.releaseCount)
637+ assert.Empty(t, sink.changes)
638+ })
639+ }
640+}
641+
642+func TestSearchHistoryUseCase_ClearAll_ReturnsCoordinatorErrorBeforeDeleting(t *testing.T) {
643+ ctx := testContext()
644+ historyRepo := repomocks.NewMockHistoryRepository(t)
645+ coordinator := &recordingHistoryMutationCoordinator{err: errors.New("flush failed")}
646+ sink := &recordingHistoryChangeSink{}
647+ uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
648+ uc.SetHistoryMutationCoordinator(coordinator)
649+
650+ err := uc.ClearAll(ctx)
651+
652+ require.Error(t, err)
653+ require.Equal(t, 1, coordinator.beginCount)
654+ require.Zero(t, coordinator.releaseCount)
655+ assert.Empty(t, sink.changes)
656+}
657+
658+func TestSearchHistoryUseCase_ClearAll_DoesNotPublishOnRepositoryError(t *testing.T) {
659+ ctx := testContext()
660+ historyRepo := repomocks.NewMockHistoryRepository(t)
661+ historyRepo.EXPECT().DeleteAll(mock.Anything).Return(errors.New("clear all failed")).Once()
662+ sink := &recordingHistoryChangeSink{}
663+ uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
664+
665+ err := uc.ClearAll(ctx)
666+
667+ require.Error(t, err)
668+ assert.Empty(t, sink.changes)
669+}
670+
671+func TestSearchHistoryUseCase_ClearOlderThan_PublishesClearChangeAfterSuccess(t *testing.T) {
672+ ctx := testContext()
673+ historyRepo := repomocks.NewMockHistoryRepository(t)
674+ before := time.Now().Add(-7 * 24 * time.Hour)
675+ historyRepo.EXPECT().DeleteOlderThan(mock.Anything, before).Return(nil).Once()
676+ sink := &recordingHistoryChangeSink{}
677+ uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
678+
679+ err := uc.ClearOlderThan(ctx, before)
680+
681+ require.NoError(t, err)
682+ requirePublishedHistoryChange(t, sink, dto.HistoryChangeReasonClear)
683+}
684+
685+func TestSearchHistoryUseCase_ClearOlderThan_DoesNotPublishOnRepositoryError(t *testing.T) {
686+ ctx := testContext()
687+ historyRepo := repomocks.NewMockHistoryRepository(t)
688+ before := time.Now().Add(-7 * 24 * time.Hour)
689+ historyRepo.EXPECT().DeleteOlderThan(mock.Anything, before).Return(errors.New("clear old failed")).Once()
690+ sink := &recordingHistoryChangeSink{}
691+ uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
692+
693+ err := uc.ClearOlderThan(ctx, before)
694+
695+ require.Error(t, err)
696+ assert.Empty(t, sink.changes)
697+}
698+
699+func TestSearchHistoryUseCase_ClearRange_RecentRangeDeletesSinceCutoffAndPublishesClearChange(t *testing.T) {
273700 ctx := testContext()
274701 historyRepo := repomocks.NewMockHistoryRepository(t)
275702 now := time.Now()
@@ -277,11 +704,26 @@ func TestSearchHistoryUseCase_ClearRange_RecentRangeDeletesSinceCutoff(t *testin
277704 historyRepo.EXPECT().DeleteSince(mock.Anything, mock.MatchedBy(func(since time.Time) bool {
278705 return since.After(expectedCutoff.Add(-time.Minute)) && since.Before(expectedCutoff.Add(time.Minute))
279706 })).Return(nil).Once()
280-
281- uc := usecase.NewSearchHistoryUseCase(historyRepo)
707+ sink := &recordingHistoryChangeSink{}
708+ uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
282709
283710 err := uc.ClearRange(ctx, "hour")
711+
284712 require.NoError(t, err)
713+ requirePublishedHistoryChange(t, sink, dto.HistoryChangeReasonClear)
714+}
715+
716+func TestSearchHistoryUseCase_ClearRange_RecentRangeDoesNotPublishOnRepositoryError(t *testing.T) {
717+ ctx := testContext()
718+ historyRepo := repomocks.NewMockHistoryRepository(t)
719+ historyRepo.EXPECT().DeleteSince(mock.Anything, mock.AnythingOfType("time.Time")).Return(errors.New("delete since failed")).Once()
720+ sink := &recordingHistoryChangeSink{}
721+ uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
722+
723+ err := uc.ClearRange(ctx, "hour")
724+
725+ require.Error(t, err)
726+ assert.Empty(t, sink.changes)
285727 }
286728
287729 func TestSearchHistoryUseCase_ClearRange_TodayDeletesSinceLocalMidnight(t *testing.T) {
@@ -299,29 +741,45 @@ func TestSearchHistoryUseCase_ClearRange_TodayDeletesSinceLocalMidnight(t *testi
299741 require.NoError(t, err)
300742 }
301743
302-func TestSearchHistoryUseCase_ClearRange_AllUsesClearAll(t *testing.T) {
744+func TestSearchHistoryUseCase_ClearRange_AllUsesClearAllAndPublishesOnce(t *testing.T) {
303745 ctx := testContext()
304746
305747 historyRepo := repomocks.NewMockHistoryRepository(t)
306748
307- historyRepo.EXPECT().DeleteAll(mock.Anything).Return(nil)
308-
309- uc := usecase.NewSearchHistoryUseCase(historyRepo)
749+ historyRepo.EXPECT().DeleteAll(mock.Anything).Return(nil).Once()
750+ sink := &recordingHistoryChangeSink{}
751+ uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
310752
311753 err := uc.ClearRange(ctx, "all")
312754 require.NoError(t, err)
755+ requirePublishedHistoryChange(t, sink, dto.HistoryChangeReasonClear)
313756 }
314757
315-func TestSearchHistoryUseCase_ClearRange_UnknownRangeDoesNotDelete(t *testing.T) {
758+func TestSearchHistoryUseCase_ClearRange_AllDoesNotPublishOnRepositoryError(t *testing.T) {
316759 ctx := testContext()
317760
318761 historyRepo := repomocks.NewMockHistoryRepository(t)
319762
320- uc := usecase.NewSearchHistoryUseCase(historyRepo)
763+ historyRepo.EXPECT().DeleteAll(mock.Anything).Return(errors.New("clear all failed")).Once()
764+ sink := &recordingHistoryChangeSink{}
765+ uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
766+
767+ err := uc.ClearRange(ctx, "all")
768+ require.Error(t, err)
769+ assert.Empty(t, sink.changes)
770+}
771+
772+func TestSearchHistoryUseCase_ClearRange_UnknownRangeDoesNotDeleteOrPublish(t *testing.T) {
773+ ctx := testContext()
774+
775+ historyRepo := repomocks.NewMockHistoryRepository(t)
776+ sink := &recordingHistoryChangeSink{}
777+ uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
321778
322779 err := uc.ClearRange(ctx, "bogus")
323780 require.Error(t, err)
324781 assert.Contains(t, err.Error(), "unknown history delete range")
782+ assert.Empty(t, sink.changes)
325783 }
326784
327785 func TestSearchHistoryUseCase_GetRecentByDomainAllowsUnderscoreDomains(t *testing.T) {
M internal/ui/app.go +22 -1
@@ -220,6 +220,14 @@ func New(deps *Dependencies) (*App, error) {
220220 glib.IdleAdd(&cb, 0)
221221 })
222222 }
223+ historyChangeSink := newHistoryChangeAdapter(app)
224+ if deps.HistoryRecorderUC != nil {
225+ deps.HistoryRecorderUC.SetHistoryChangeSink(historyChangeSink)
226+ }
227+ if deps.HistoryUC != nil {
228+ deps.HistoryUC.SetHistoryChangeSink(historyChangeSink)
229+ }
230+
223231 if faviconSetter, ok := deps.Engine.(port.SystemviewFaviconResolverSetter); ok {
224232 resolver := deps.SystemviewFaviconResolver
225233 if resolver == nil {
@@ -249,6 +257,10 @@ func New(deps *Dependencies) (*App, error) {
249257 },
250258 HandlerDeps: deps.HandlerDeps,
251259 }); err != nil {
260+ cancel(err)
261+ if deps.HistoryRecorderUC != nil {
262+ deps.HistoryRecorderUC.Close()
263+ }
252264 return nil, err
253265 }
254266
@@ -3145,6 +3157,7 @@ func (a *App) onShutdown(ctx context.Context) {
31453157 a.cancel(errors.New("application shutdown"))
31463158
31473159 // Cleanup resources
3160+ a.closeHistoryRecorder()
31483161 if a.faviconAdapter != nil {
31493162 a.faviconAdapter.Close()
31503163 }
@@ -3183,6 +3196,13 @@ func (a *App) onShutdown(ctx context.Context) {
31833196 log.Info().Msg("application shutdown complete")
31843197 }
31853198
3199+func (a *App) closeHistoryRecorder() {
3200+ if a.deps == nil || a.deps.HistoryRecorderUC == nil {
3201+ return
3202+ }
3203+ a.deps.HistoryRecorderUC.Close()
3204+}
3205+
31863206 // initContentCoordinator creates the content coordinator and wires its optional dependencies.
31873207 func (a *App) initContentCoordinator(
31883208 ctx context.Context,
@@ -3396,9 +3416,10 @@ func (a *App) initCoordinators(ctx context.Context) {
33963416 a.extractPaneToTabListUC = usecase.NewExtractPaneToTabListUseCase(a.generateID)
33973417
33983418 // 4. Navigation Coordinator
3399- a.navCoord = coordinator.NewNavigationCoordinator(
3419+ a.navCoord = coordinator.NewNavigationCoordinatorWithHistoryRecorder(
34003420 ctx,
34013421 a.deps.NavigateUC,
3422+ a.deps.HistoryRecorderUC,
34023423 a.contentCoord,
34033424 )
34043425 a.wsCoord.SetOnPaneClosed(func(paneID entity.PaneID) {
M internal/ui/app_clipboard_test.go +32 -0
@@ -2,12 +2,15 @@ package ui
22
33 import (
44 "context"
5+ "errors"
56 "testing"
67
78 "github.com/bnema/dumber/internal/application/dto"
89 "github.com/bnema/dumber/internal/application/port"
910 portmocks "github.com/bnema/dumber/internal/application/port/mocks"
11+ "github.com/bnema/dumber/internal/application/usecase"
1012 "github.com/bnema/dumber/internal/domain/entity"
13+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
1114 "github.com/stretchr/testify/mock"
1215 "github.com/stretchr/testify/require"
1316 )
@@ -45,6 +48,35 @@ func TestNew_DoesNotWireClipboardOrchestratorWhenClipboardMissing(t *testing.T)
4548 require.Nil(t, registerDeps.ClipboardTextOrchestrator)
4649 }
4750
51+func TestNew_ClosesHistoryRecorderWhenRegisterHandlersFails(t *testing.T) {
52+ ctx := context.Background()
53+ engine := portmocks.NewMockEngine(t)
54+ registerErr := errors.New("register failed")
55+ engine.EXPECT().RegisterHandlers(mock.Anything, mock.Anything).Return(registerErr).Once()
56+ runtimeConfig := portmocks.NewMockRuntimeConfigProvider(t)
57+ runtimeConfig.EXPECT().Current().Return(entity.RuntimeConfigSnapshot{}).Once()
58+ historyRepo := repomocks.NewMockHistoryRepository(t)
59+ historyRecorder := usecase.NewHistoryRecorderUseCase(historyRepo, nil)
60+
61+ deps := &Dependencies{
62+ Ctx: ctx,
63+ RuntimeConfig: runtimeConfig,
64+ Engine: engine,
65+ HistoryRecorderUC: historyRecorder,
66+ HandlerDeps: port.HandlerDeps{
67+ SaveConfig: func(context.Context, dto.WebUIConfig) error { return nil },
68+ SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil },
69+ },
70+ }
71+
72+ app, err := New(deps)
73+ require.Nil(t, app)
74+ require.ErrorIs(t, err, registerErr)
75+
76+ historyRecorder.RecordHistory(ctx, "pane-1", "https://example.com/leak-check")
77+ historyRecorder.Close()
78+}
79+
4880 func TestNew_WiresClipboardOrchestratorWhenClipboardPresent(t *testing.T) {
4981 ctx := context.Background()
5082 engine := portmocks.NewMockEngine(t)
M internal/ui/browser_window.go +22 -20
@@ -20,26 +20,27 @@ import (
2020 )
2121
2222 type browserWindow struct {
23- id string
24- initialURL string
25- tabs *entity.TabList // per-window tab list (single source of truth for tab state)
26- mainWindow *window.MainWindow
27- appToaster *component.Toaster
28- modeToaster *component.Toaster
29- touchpadNavIndicator *component.TouchpadNavigationIndicator
30- borderMgr *focus.BorderManager
31- sessionManager *component.SessionManager
32- tabPicker *component.TabPicker
33- tabPickerWidget layout.Widget
34- tabPickerPaneID entity.PaneID
35- insertAccentUC *usecase.InsertAccentUseCase
36- accentPicker *component.AccentPicker
37- keyboardHandler *input.KeyboardHandler
38- globalShortcutHandler *input.GlobalShortcutHandler
39- permissionDialog port.PermissionDialogPresenter
40- webrtcIndicator *component.WebRTCPermissionIndicator
41- historySidebar *component.HistorySidebar
42- sidebarVisible bool
23+ id string
24+ initialURL string
25+ tabs *entity.TabList // per-window tab list (single source of truth for tab state)
26+ mainWindow *window.MainWindow
27+ appToaster *component.Toaster
28+ modeToaster *component.Toaster
29+ touchpadNavIndicator *component.TouchpadNavigationIndicator
30+ borderMgr *focus.BorderManager
31+ sessionManager *component.SessionManager
32+ tabPicker *component.TabPicker
33+ tabPickerWidget layout.Widget
34+ tabPickerPaneID entity.PaneID
35+ insertAccentUC *usecase.InsertAccentUseCase
36+ accentPicker *component.AccentPicker
37+ keyboardHandler *input.KeyboardHandler
38+ globalShortcutHandler *input.GlobalShortcutHandler
39+ permissionDialog port.PermissionDialogPresenter
40+ webrtcIndicator *component.WebRTCPermissionIndicator
41+ historySidebar *component.HistorySidebar
42+ historySidebarReloader historySidebarReloader
43+ sidebarVisible bool
4344 }
4445
4546 func (bw *browserWindow) detachInputForDestroy() {
@@ -90,6 +91,7 @@ func (bw *browserWindow) clearShellState() {
9091 bw.permissionDialog = nil
9192 bw.webrtcIndicator = nil
9293 bw.historySidebar = nil
94+ bw.historySidebarReloader = nil
9395 }
9496
9597 func (bw *browserWindow) initChrome(ctx context.Context, a *App) {
M internal/ui/browser_window_history_sidebar_test.go +86 -0
@@ -4,9 +4,11 @@ import (
44 "context"
55 "testing"
66
7+ "github.com/bnema/dumber/internal/application/dto"
78 "github.com/bnema/dumber/internal/application/usecase"
89 "github.com/bnema/dumber/internal/domain/entity"
910 "github.com/bnema/dumber/internal/infrastructure/config"
11+ "github.com/bnema/dumber/internal/shared/syncdispatch"
1012 "github.com/bnema/dumber/internal/ui/component"
1113 "github.com/bnema/dumber/internal/ui/coordinator"
1214 contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"
@@ -889,3 +891,87 @@ func TestBrowserWindow_ApplySidebarWidthConfig_NilDepsIsSafe(t *testing.T) {
889891 app := &App{deps: nil}
890892 require.NotPanics(t, func() { bw.applySidebarWidthConfig(app) })
891893 }
894+
895+type recordingHistorySidebarReloader struct {
896+ calls int
897+ reasons []string
898+}
899+
900+func (r *recordingHistorySidebarReloader) RequestReloadIfVisible(reason string) {
901+ r.calls++
902+ r.reasons = append(r.reasons, reason)
903+}
904+
905+func TestBrowserWindow_ClearShellStateClearsHistorySidebarReloader(t *testing.T) {
906+ reloader := &recordingHistorySidebarReloader{}
907+ bw := &browserWindow{
908+ historySidebar: &component.HistorySidebar{},
909+ historySidebarReloader: reloader,
910+ }
911+
912+ bw.clearShellState()
913+
914+ assert.Nil(t, bw.historySidebar)
915+ assert.Nil(t, bw.historySidebarReloader)
916+}
917+
918+func TestHistoryChangeAdapter_DispatchesBeforeRefreshingSidebars(t *testing.T) {
919+ reloader := &recordingHistorySidebarReloader{}
920+ bw := &browserWindow{id: "window-1", sidebarVisible: true, historySidebarReloader: reloader}
921+ var dispatched bool
922+ var directCalls int
923+ app := &App{
924+ browserWindows: map[string]*browserWindow{bw.id: bw},
925+ dispatchOnMainThread: func(label string, fn func()) syncdispatch.SyncDispatchResult {
926+ dispatched = true
927+ assert.Equal(t, "ui.history_sidebar_refresh", label)
928+ assert.Zero(t, reloader.calls, "sidebar must not be touched before main-thread dispatch runs")
929+ directCalls = reloader.calls
930+ fn()
931+ return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchCompleted}
932+ },
933+ }
934+
935+ newHistoryChangeAdapter(app).OnHistoryChanged(t.Context(), dto.HistoryChange{Reasons: []dto.HistoryChangeReason{dto.HistoryChangeReasonVisit}})
936+
937+ assert.True(t, dispatched)
938+ assert.Zero(t, directCalls)
939+ assert.Equal(t, 1, reloader.calls)
940+ assert.Equal(t, []string{"visit"}, reloader.reasons)
941+}
942+
943+func TestHistoryChangeAdapter_SkipsRefreshWithoutDispatcher(t *testing.T) {
944+ reloader := &recordingHistorySidebarReloader{}
945+ bw := &browserWindow{id: "window-1", sidebarVisible: true, historySidebarReloader: reloader}
946+ app := &App{browserWindows: map[string]*browserWindow{bw.id: bw}}
947+
948+ newHistoryChangeAdapter(app).OnHistoryChanged(
949+ t.Context(),
950+ dto.HistoryChange{Reasons: []dto.HistoryChangeReason{dto.HistoryChangeReasonVisit}},
951+ )
952+
953+ assert.Zero(t, reloader.calls)
954+}
955+
956+func TestApp_RefreshVisibleHistorySidebars_OnlyVisibleInitializedWindows(t *testing.T) {
957+ visibleOne := &recordingHistorySidebarReloader{}
958+ visibleTwo := &recordingHistorySidebarReloader{}
959+ hidden := &recordingHistorySidebarReloader{}
960+ app := &App{browserWindows: map[string]*browserWindow{
961+ "visible-1": {id: "visible-1", sidebarVisible: true, historySidebarReloader: visibleOne},
962+ "hidden": {id: "hidden", sidebarVisible: false, historySidebarReloader: hidden},
963+ "uninitialized": {id: "uninitialized", sidebarVisible: true},
964+ "visible-2": {id: "visible-2", sidebarVisible: true, historySidebarReloader: visibleTwo},
965+ "destroyed-no-op": nil,
966+ }}
967+
968+ require.NotPanics(t, func() {
969+ app.refreshVisibleHistorySidebars(dto.HistoryChange{Reasons: []dto.HistoryChangeReason{dto.HistoryChangeReasonClear}})
970+ })
971+
972+ assert.Equal(t, 1, visibleOne.calls)
973+ assert.Equal(t, 1, visibleTwo.calls)
974+ assert.Zero(t, hidden.calls)
975+ assert.Equal(t, []string{"clear"}, visibleOne.reasons)
976+ assert.Equal(t, []string{"clear"}, visibleTwo.reasons)
977+}
M internal/ui/component/history_model.go +8 -2
@@ -36,11 +36,14 @@ const (
3636 // "January 2, 2006" (other years). Within each group entries remain in
3737 // the order they arrived (assumed most-recent-first).
3838 func groupHistoryByDay(entries []*entity.HistoryEntry) []historyGroup {
39+ return groupHistoryByDayAt(entries, time.Now())
40+}
41+
42+func groupHistoryByDayAt(entries []*entity.HistoryEntry, now time.Time) []historyGroup {
3943 if len(entries) == 0 {
4044 return nil
4145 }
4246
43- now := time.Now()
4447 local := now.Location()
4548 todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, local)
4649
@@ -108,7 +111,10 @@ func readableURL(rawURL string) string {
108111 // relativeTime returns a compact relative time label for a timestamp.
109112 // Examples: "2m ago", "1h ago", "3d ago", "Jan 2".
110113 func relativeTime(t time.Time) string {
111- now := time.Now()
114+ return relativeTimeAt(t, time.Now())
115+}
116+
117+func relativeTimeAt(t, now time.Time) string {
112118 d := now.Sub(t)
113119
114120 switch {
M internal/ui/component/history_model_test.go +4 -4
@@ -46,12 +46,12 @@ func TestGroupHistoryByDay_TodayYesterdayOlder(t *testing.T) {
4646 }
4747
4848 func TestGroupHistoryByDay_SameDayEntriesGrouped(t *testing.T) {
49- now := time.Now()
49+ now := time.Date(2026, time.June, 24, 12, 0, 0, 0, time.Local)
5050 entries := []*entity.HistoryEntry{
5151 {ID: 1, URL: "https://a.com", Title: "A", LastVisited: now},
5252 {ID: 2, URL: "https://b.com", Title: "B", LastVisited: now.Add(-time.Hour)},
5353 }
54- groups := groupHistoryByDay(entries)
54+ groups := groupHistoryByDayAt(entries, now)
5555 require.Len(t, groups, 1)
5656 assert.Equal(t, "Today", groups[0].Label)
5757 assert.Len(t, groups[0].Entries, 2)
@@ -106,13 +106,13 @@ func TestGroupHistoryByDay_CrossYearDifferentLabels(t *testing.T) {
106106 }
107107
108108 func TestGroupHistoryByDay_MaintainsInputOrderWithinDay(t *testing.T) {
109- now := time.Now()
109+ now := time.Date(2026, time.June, 24, 12, 0, 0, 0, time.Local)
110110 entries := []*entity.HistoryEntry{
111111 {ID: 1, URL: "https://first.com", Title: "First", LastVisited: now},
112112 {ID: 2, URL: "https://second.com", Title: "Second", LastVisited: now.Add(-30 * time.Minute)},
113113 {ID: 3, URL: "https://third.com", Title: "Third", LastVisited: now.Add(-2 * time.Hour)},
114114 }
115- groups := groupHistoryByDay(entries)
115+ groups := groupHistoryByDayAt(entries, now)
116116 require.Len(t, groups, 1)
117117 require.Len(t, groups[0].Entries, 3)
118118 // Entries within the same day maintain input order
M internal/ui/component/history_sidebar.go +44 -5
@@ -3,6 +3,7 @@ package component
33 import (
44 "context"
55 "sync"
6+ "time"
67
78 "github.com/bnema/puregotk/v4/glib"
89 "github.com/bnema/puregotk/v4/gtk"
@@ -19,6 +20,8 @@ const (
1920 sidebarMinWidth = 280
2021 sidebarDefaultWidth = 320
2122 sidebarSearchDebounceMs = 150
23+ sidebarReloadDebounceMs = 200
24+ sidebarRelativeTickMs = 60 * 1000
2225 sidebarPageSize = 100 // entries fetched per page
2326 sidebarSearchLimit = 100 // max FTS search results
2427 )
@@ -86,8 +89,14 @@ type HistorySidebar struct {
8689 prevScrollValue float64
8790 prevSelectedURL string
8891
89- // Search debounce timer
90- debounceTimer uint
92+ // Search/reload/relative-time timers. GLib source IDs are owned and
93+ // created/removed on the GTK main context.
94+ debounceTimer uint
95+ reloadDebounceTimer uint
96+ relativeTimeTicker uint
97+ relativeTimeLabelBinds []relativeTimeLabelBinding
98+ relativeTimeDayKey dayKey
99+ relativeTimeDayKeySet bool
91100
92101 // Retained callbacks
93102 retainedCallbacks []interface{}
@@ -95,6 +104,9 @@ type HistorySidebar struct {
95104 // idleScheduler dispatches work onto the GTK main thread.
96105 // Tests may override it to exercise scheduled callbacks deterministically.
97106 idleScheduler func(glib.SourceFunc)
107+ timeoutAdd func(uint, glib.SourceFunc) uint
108+ sourceRemove func(uint)
109+ now func() time.Time
98110
99111 // Context
100112 ctx context.Context
@@ -202,6 +214,10 @@ func (hs *HistorySidebar) Destroy() {
202214 hs.destroyed = true
203215 timerID := hs.debounceTimer
204216 hs.debounceTimer = 0
217+ reloadTimerID := hs.cancelReloadDebounceLocked()
218+ tickerID := hs.relativeTimeTicker
219+ hs.relativeTimeTicker = 0
220+ hs.clearRelativeTimeBindingsLocked()
205221 hs.mu.Unlock()
206222
207223 if hs.cancel != nil {
@@ -209,7 +225,13 @@ func (hs *HistorySidebar) Destroy() {
209225 }
210226
211227 if timerID != 0 {
212- glib.SourceRemove(timerID)
228+ hs.removeSource(timerID)
229+ }
230+ if reloadTimerID != 0 {
231+ hs.removeSource(reloadTimerID)
232+ }
233+ if tickerID != 0 {
234+ hs.removeSource(tickerID)
213235 }
214236 }
215237
@@ -239,10 +261,17 @@ func (hs *HistorySidebar) Show() {
239261 return
240262 }
241263
264+ reloadTimerID := hs.reloadDebounceTimer
265+ hs.reloadDebounceTimer = 0
242266 hs.outerBox.SetVisible(true)
243267 hs.visible = true
244268 hs.mu.Unlock()
245269
270+ if reloadTimerID != 0 {
271+ hs.removeSource(reloadTimerID)
272+ }
273+ hs.startRelativeTimeTicker()
274+
246275 // Schedule a background reload so the sidebar shows fresh data
247276 // when it becomes visible, not stale data captured at init time.
248277 reloadCb := glib.SourceFunc(func(uintptr) bool {
@@ -275,14 +304,24 @@ func (hs *HistorySidebar) Show() {
275304 // Hide hides the sidebar.
276305 func (hs *HistorySidebar) Hide() {
277306 hs.mu.Lock()
278- defer hs.mu.Unlock()
279-
280307 if hs.outerBox == nil || hs.destroyed {
308+ hs.mu.Unlock()
281309 return
282310 }
283311
312+ reloadTimerID := hs.cancelReloadDebounceLocked()
313+ tickerID := hs.relativeTimeTicker
314+ hs.relativeTimeTicker = 0
284315 hs.outerBox.SetVisible(false)
285316 hs.visible = false
317+ hs.mu.Unlock()
318+
319+ if reloadTimerID != 0 {
320+ hs.removeSource(reloadTimerID)
321+ }
322+ if tickerID != 0 {
323+ hs.removeSource(tickerID)
324+ }
286325 }
287326
288327 // IsVisible returns whether the sidebar is visible.
M internal/ui/component/history_sidebar_loading_search.go +11 -8
@@ -184,10 +184,10 @@ func (hs *HistorySidebar) preserveScrollAndSelection() {
184184
185185 // restoreScrollAndSelection restores the previously saved scroll position and
186186 // selection after a rebuild. Called on the GTK main thread.
187-func (hs *HistorySidebar) restoreScrollAndSelection() {
187+func (hs *HistorySidebar) restoreScrollAndSelectionInListBox(listBox *gtk.ListBox) {
188188 // Restore selection first (changes scroll position)
189189 if hs.prevSelectedURL != "" {
190- hs.selectRowByURL(hs.prevSelectedURL)
190+ hs.selectRowByURLInListBox(listBox, hs.prevSelectedURL)
191191 }
192192
193193 // Then restore scroll position if we have one
@@ -232,12 +232,12 @@ func (hs *HistorySidebar) entryURLAtIndex(index int) string {
232232 }
233233
234234 // selectRowByURL finds and selects a row whose URL matches.
235-func (hs *HistorySidebar) selectRowByURL(url string) {
236- if url == "" || hs.listBox == nil {
235+func (hs *HistorySidebar) selectRowByURLInListBox(listBox *gtk.ListBox, url string) {
236+ if url == "" || listBox == nil {
237237 return
238238 }
239239 for i := 0; ; i++ {
240- row := hs.listBox.GetRowAtIndex(i)
240+ row := listBox.GetRowAtIndex(i)
241241 if row == nil {
242242 break
243243 }
@@ -245,7 +245,7 @@ func (hs *HistorySidebar) selectRowByURL(url string) {
245245 continue
246246 }
247247 if hs.getRowURL(row) == url {
248- hs.listBox.SelectRow(row)
248+ listBox.SelectRow(row)
249249 return
250250 }
251251 }
@@ -280,7 +280,7 @@ func (hs *HistorySidebar) onSearchChanged() {
280280 hs.mu.Unlock()
281281
282282 if oldTimer != 0 {
283- glib.SourceRemove(oldTimer)
283+ hs.removeSource(oldTimer)
284284 }
285285
286286 filterCb := glib.SourceFunc(func(uintptr) bool {
@@ -293,7 +293,7 @@ func (hs *HistorySidebar) onSearchChanged() {
293293 if hs.destroyed {
294294 hs.mu.Unlock()
295295 if timerID != 0 {
296- glib.SourceRemove(timerID)
296+ hs.removeSource(timerID)
297297 }
298298 return
299299 }
@@ -419,6 +419,9 @@ func (hs *HistorySidebar) scheduleClearList() {
419419 if destroyed || listBox == nil {
420420 return false
421421 }
422+ hs.mu.Lock()
423+ hs.clearRelativeTimeBindingsLocked()
424+ hs.mu.Unlock()
422425 listBox.RemoveAll()
423426 return false
424427 })
A internal/ui/component/history_sidebar_reactivity.go +215 -0
@@ -0,0 +1,215 @@
1+package component
2+
3+import (
4+ "time"
5+
6+ "github.com/bnema/puregotk/v4/glib"
7+ "github.com/bnema/puregotk/v4/gtk"
8+)
9+
10+type relativeTimeLabelBinding struct {
11+ label *gtk.Label
12+ lastVisited time.Time
13+}
14+
15+func (hs *HistorySidebar) RequestReloadIfVisible(reason string) {
16+ _ = reason
17+ if hs == nil {
18+ return
19+ }
20+ hs.mu.RLock()
21+ if hs.destroyed || !hs.visible {
22+ hs.mu.RUnlock()
23+ return
24+ }
25+ hs.mu.RUnlock()
26+ cb := glib.SourceFunc(func(uintptr) bool {
27+ hs.requestReloadIfVisibleOnMain()
28+ return false
29+ })
30+ hs.scheduleIdle(cb)
31+}
32+
33+func (hs *HistorySidebar) requestReloadIfVisibleOnMain() {
34+ hs.mu.Lock()
35+ if hs.destroyed || !hs.visible {
36+ hs.mu.Unlock()
37+ return
38+ }
39+ oldTimer := hs.reloadDebounceTimer
40+ hs.reloadDebounceTimer = 0
41+ hs.mu.Unlock()
42+
43+ if oldTimer != 0 {
44+ hs.removeSource(oldTimer)
45+ }
46+
47+ var timerID uint
48+ reloadCb := glib.SourceFunc(func(uintptr) bool {
49+ hs.mu.Lock()
50+ if hs.reloadDebounceTimer != timerID {
51+ hs.mu.Unlock()
52+ return false
53+ }
54+ hs.reloadDebounceTimer = 0
55+ if hs.destroyed || !hs.visible {
56+ hs.mu.Unlock()
57+ return false
58+ }
59+ hs.mu.Unlock()
60+ hs.Reload()
61+ return false
62+ })
63+ timerID = hs.addTimeout(sidebarReloadDebounceMs, reloadCb)
64+
65+ hs.mu.Lock()
66+ if hs.destroyed || !hs.visible {
67+ hs.mu.Unlock()
68+ if timerID != 0 {
69+ hs.removeSource(timerID)
70+ }
71+ return
72+ }
73+ hs.reloadDebounceTimer = timerID
74+ hs.mu.Unlock()
75+}
76+
77+func (hs *HistorySidebar) cancelReloadDebounceLocked() uint {
78+ timerID := hs.reloadDebounceTimer
79+ hs.reloadDebounceTimer = 0
80+ return timerID
81+}
82+
83+func (hs *HistorySidebar) bindRelativeTimeLabel(label *gtk.Label, visited time.Time) {
84+ if label == nil {
85+ return
86+ }
87+ hs.mu.Lock()
88+ if !hs.destroyed {
89+ hs.relativeTimeLabelBinds = append(hs.relativeTimeLabelBinds, relativeTimeLabelBinding{label: label, lastVisited: visited})
90+ }
91+ hs.mu.Unlock()
92+}
93+
94+func (hs *HistorySidebar) clearRelativeTimeBindingsLocked() {
95+ hs.relativeTimeLabelBinds = nil
96+}
97+
98+func (hs *HistorySidebar) startRelativeTimeTicker() {
99+ hs.mu.Lock()
100+ if hs.destroyed || !hs.visible {
101+ hs.mu.Unlock()
102+ return
103+ }
104+ oldTicker := hs.relativeTimeTicker
105+ hs.relativeTimeTicker = 0
106+ hs.relativeTimeDayKey = hs.currentDayKey()
107+ hs.relativeTimeDayKeySet = true
108+ hs.mu.Unlock()
109+
110+ if oldTicker != 0 {
111+ hs.removeSource(oldTicker)
112+ }
113+
114+ hs.scheduleRelativeTimeRefresh()
115+
116+ var tickerID uint
117+ tickCb := glib.SourceFunc(func(uintptr) bool {
118+ hs.mu.RLock()
119+ keep := !hs.destroyed && hs.visible && hs.relativeTimeTicker == tickerID
120+ hs.mu.RUnlock()
121+ if !keep {
122+ return false
123+ }
124+ hs.scheduleRelativeTimeRefresh()
125+ return true
126+ })
127+ tickerID = hs.addTimeout(sidebarRelativeTickMs, tickCb)
128+
129+ hs.mu.Lock()
130+ if hs.destroyed || !hs.visible {
131+ hs.mu.Unlock()
132+ if tickerID != 0 {
133+ hs.removeSource(tickerID)
134+ }
135+ return
136+ }
137+ hs.relativeTimeTicker = tickerID
138+ hs.mu.Unlock()
139+}
140+
141+func (hs *HistorySidebar) scheduleRelativeTimeRefresh() {
142+ cb := glib.SourceFunc(func(uintptr) bool {
143+ hs.updateRelativeTimeLabelsOnMain()
144+ return false
145+ })
146+ hs.scheduleIdle(cb)
147+}
148+
149+func (hs *HistorySidebar) updateRelativeTimeLabelsOnMain() {
150+ now := hs.currentTime()
151+ hs.mu.RLock()
152+ if hs.destroyed || !hs.visible {
153+ hs.mu.RUnlock()
154+ return
155+ }
156+ currentDay := dayKeyFromTime(now)
157+ lastDay := hs.relativeTimeDayKey
158+ daySet := hs.relativeTimeDayKeySet
159+ bindings := append([]relativeTimeLabelBinding(nil), hs.relativeTimeLabelBinds...)
160+ hs.mu.RUnlock()
161+
162+ if daySet && currentDay != lastDay {
163+ shouldReload := false
164+ hs.mu.Lock()
165+ if !hs.destroyed && hs.visible && hs.relativeTimeDayKeySet && hs.relativeTimeDayKey != currentDay {
166+ hs.relativeTimeDayKey = currentDay
167+ shouldReload = true
168+ }
169+ hs.mu.Unlock()
170+ if shouldReload {
171+ hs.RequestReloadIfVisible("day-boundary")
172+ }
173+ return
174+ }
175+
176+ for _, binding := range bindings {
177+ if binding.label == nil {
178+ continue
179+ }
180+ binding.label.SetText(relativeTimeAt(binding.lastVisited, now))
181+ }
182+}
183+
184+func (hs *HistorySidebar) currentTime() time.Time {
185+ if hs != nil && hs.now != nil {
186+ return hs.now()
187+ }
188+ return time.Now()
189+}
190+
191+func (hs *HistorySidebar) currentDayKey() dayKey {
192+ return dayKeyFromTime(hs.currentTime())
193+}
194+
195+func dayKeyFromTime(t time.Time) dayKey {
196+ return dayKey{year: t.Year(), month: t.Month(), day: t.Day()}
197+}
198+
199+func (hs *HistorySidebar) addTimeout(ms uint, cb glib.SourceFunc) uint {
200+ if hs != nil && hs.timeoutAdd != nil {
201+ return hs.timeoutAdd(ms, cb)
202+ }
203+ return glib.TimeoutAdd(ms, &cb, 0)
204+}
205+
206+func (hs *HistorySidebar) removeSource(id uint) {
207+ if id == 0 {
208+ return
209+ }
210+ if hs != nil && hs.sourceRemove != nil {
211+ hs.sourceRemove(id)
212+ return
213+ }
214+ glib.SourceRemove(id)
215+}
A internal/ui/component/history_sidebar_reactivity_test.go +257 -0
@@ -0,0 +1,257 @@
1+package component
2+
3+import (
4+ "sync"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/puregotk/v4/glib"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+type sidebarReactivityHarness struct {
15+ hs *HistorySidebar
16+ idle []glib.SourceFunc
17+ timers map[uint]glib.SourceFunc
18+ removed []uint
19+ nextID uint
20+ idleMu sync.Mutex
21+ timerMu sync.Mutex
22+}
23+
24+func newSidebarReactivityHarness() *sidebarReactivityHarness {
25+ h := &sidebarReactivityHarness{timers: make(map[uint]glib.SourceFunc), nextID: 10}
26+ hs := newTestSidebarSearchHarness()
27+ hs.now = func() time.Time { return time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC) }
28+ hs.idleScheduler = func(cb glib.SourceFunc) {
29+ h.idleMu.Lock()
30+ defer h.idleMu.Unlock()
31+ h.idle = append(h.idle, cb)
32+ }
33+ hs.timeoutAdd = func(_ uint, cb glib.SourceFunc) uint {
34+ h.timerMu.Lock()
35+ defer h.timerMu.Unlock()
36+ h.nextID++
37+ h.timers[h.nextID] = cb
38+ return h.nextID
39+ }
40+ hs.sourceRemove = func(id uint) {
41+ h.timerMu.Lock()
42+ defer h.timerMu.Unlock()
43+ h.removed = append(h.removed, id)
44+ delete(h.timers, id)
45+ }
46+ h.hs = hs
47+ return h
48+}
49+
50+func (h *sidebarReactivityHarness) runIdle() int {
51+ h.idleMu.Lock()
52+ callbacks := append([]glib.SourceFunc(nil), h.idle...)
53+ h.idle = nil
54+ h.idleMu.Unlock()
55+ for _, cb := range callbacks {
56+ cb(0)
57+ }
58+ return len(callbacks)
59+}
60+
61+func (h *sidebarReactivityHarness) singleTimer(t *testing.T) (uint, glib.SourceFunc) {
62+ t.Helper()
63+ h.timerMu.Lock()
64+ defer h.timerMu.Unlock()
65+ require.Len(t, h.timers, 1)
66+ for id, cb := range h.timers {
67+ return id, cb
68+ }
69+ return 0, nil
70+}
71+
72+func TestHistorySidebar_RequestReloadIfVisible_HiddenNoop(t *testing.T) {
73+ h := newSidebarReactivityHarness()
74+ h.hs.RequestReloadIfVisible("hidden")
75+ require.Zero(t, h.runIdle())
76+ assert.Empty(t, h.timers)
77+ assert.Zero(t, h.hs.reloadDebounceTimer)
78+}
79+
80+func TestHistorySidebar_RequestReloadIfVisible_CoalescesVisible(t *testing.T) {
81+ h := newSidebarReactivityHarness()
82+ h.hs.visible = true
83+ h.hs.RequestReloadIfVisible("a")
84+ h.hs.RequestReloadIfVisible("b")
85+ require.Equal(t, 2, h.runIdle())
86+ assert.Len(t, h.timers, 1)
87+ assert.Len(t, h.removed, 1, "first debounce source should be removed")
88+ assert.NotZero(t, h.hs.reloadDebounceTimer)
89+}
90+
91+func TestHistorySidebar_ReloadDebounceCallbackReloadsOnceAndClearsTimer(t *testing.T) {
92+ h := newSidebarReactivityHarness()
93+ h.hs.visible = true
94+ h.hs.RequestReloadIfVisible("history-change")
95+ require.Equal(t, 1, h.runIdle())
96+ timerID, cb := h.singleTimer(t)
97+ require.Equal(t, timerID, h.hs.reloadDebounceTimer)
98+
99+ keep := cb(0)
100+
101+ assert.False(t, keep, "debounce timeout should be one-shot")
102+ assert.Zero(t, h.hs.reloadDebounceTimer)
103+ assert.True(t, h.hs.loadStarted, "debounce should call Reload and start a history load")
104+}
105+
106+func TestHistorySidebar_ReloadDebounceCallbackSkipsHiddenSidebar(t *testing.T) {
107+ h := newSidebarReactivityHarness()
108+ h.hs.visible = true
109+ h.hs.RequestReloadIfVisible("history-change")
110+ require.Equal(t, 1, h.runIdle())
111+ _, cb := h.singleTimer(t)
112+ h.hs.visible = false
113+
114+ keep := cb(0)
115+
116+ assert.False(t, keep, "debounce timeout should be one-shot")
117+ assert.Zero(t, h.hs.reloadDebounceTimer)
118+ assert.False(t, h.hs.loadStarted, "hidden sidebar should not reload when debounce fires")
119+}
120+
121+func TestHistorySidebar_ReloadDebounceCallbackIgnoresSupersededTimer(t *testing.T) {
122+ h := newSidebarReactivityHarness()
123+ h.hs.visible = true
124+ h.hs.RequestReloadIfVisible("first")
125+ require.Equal(t, 1, h.runIdle())
126+ firstID, staleCB := h.singleTimer(t)
127+
128+ h.hs.RequestReloadIfVisible("second")
129+ require.Equal(t, 1, h.runIdle())
130+ currentID, currentCB := h.singleTimer(t)
131+ require.NotEqual(t, firstID, currentID)
132+ require.Equal(t, currentID, h.hs.reloadDebounceTimer)
133+
134+ keep := staleCB(0)
135+
136+ assert.False(t, keep, "stale debounce timeout should be one-shot")
137+ assert.Equal(t, currentID, h.hs.reloadDebounceTimer)
138+ assert.False(t, h.hs.loadStarted, "superseded debounce should not reload")
139+
140+ keep = currentCB(0)
141+ assert.False(t, keep)
142+ assert.Zero(t, h.hs.reloadDebounceTimer)
143+ assert.True(t, h.hs.loadStarted, "current debounce should still reload")
144+}
145+
146+func TestHistorySidebar_DestroyCancelsReloadTickerAndClearsBindings(t *testing.T) {
147+ h := newSidebarReactivityHarness()
148+ h.hs.visible = true
149+ h.hs.reloadDebounceTimer = 51
150+ h.hs.relativeTimeTicker = 52
151+ h.hs.relativeTimeLabelBinds = []relativeTimeLabelBinding{{lastVisited: h.hs.currentTime()}}
152+
153+ h.hs.Destroy()
154+
155+ h.hs.mu.RLock()
156+ destroyed := h.hs.destroyed
157+ reloadTimer := h.hs.reloadDebounceTimer
158+ ticker := h.hs.relativeTimeTicker
159+ bindings := h.hs.relativeTimeLabelBinds
160+ h.hs.mu.RUnlock()
161+ assert.True(t, destroyed)
162+ assert.Zero(t, reloadTimer)
163+ assert.Zero(t, ticker)
164+ assert.Empty(t, bindings)
165+ assert.Contains(t, h.removed, uint(51))
166+ assert.Contains(t, h.removed, uint(52))
167+}
168+
169+func TestHistorySidebar_RelativeTimeBindingsLifecycleAndTicker(t *testing.T) {
170+ h := newSidebarReactivityHarness()
171+ h.hs.visible = true
172+ h.hs.bindRelativeTimeLabel(nil, h.hs.currentTime())
173+ assert.Empty(t, h.hs.relativeTimeLabelBinds, "nil labels are skipped")
174+ h.hs.relativeTimeLabelBinds = []relativeTimeLabelBinding{{lastVisited: h.hs.currentTime().Add(-time.Minute)}}
175+ h.hs.mu.Lock()
176+ h.hs.clearRelativeTimeBindingsLocked()
177+ h.hs.mu.Unlock()
178+ assert.Empty(t, h.hs.relativeTimeLabelBinds)
179+
180+ h.hs.startRelativeTimeTicker()
181+ assert.NotZero(t, h.hs.relativeTimeTicker)
182+ assert.Len(t, h.timers, 1)
183+ assert.Equal(t, 1, h.runIdle(), "initial label refresh is scheduled through idle path")
184+
185+ ticker := h.hs.relativeTimeTicker
186+ h.hs.visible = false
187+ h.hs.relativeTimeTicker = 0
188+ h.hs.removeSource(ticker)
189+ assert.Contains(t, h.removed, ticker)
190+}
191+
192+func TestHistorySidebar_RelativeTimeTickerIgnoresSupersededTimer(t *testing.T) {
193+ h := newSidebarReactivityHarness()
194+ h.hs.visible = true
195+
196+ h.hs.startRelativeTimeTicker()
197+ firstID, staleCB := h.singleTimer(t)
198+ require.Equal(t, 1, h.runIdle(), "clear initial refresh")
199+
200+ h.hs.startRelativeTimeTicker()
201+ currentID, currentCB := h.singleTimer(t)
202+ require.NotEqual(t, firstID, currentID)
203+ require.Equal(t, currentID, h.hs.relativeTimeTicker)
204+ require.Equal(t, 1, h.runIdle(), "clear replacement initial refresh")
205+
206+ keep := staleCB(0)
207+
208+ assert.False(t, keep, "stale ticker should stop")
209+ assert.Equal(t, currentID, h.hs.relativeTimeTicker)
210+ assert.Zero(t, h.runIdle(), "stale ticker should not schedule refresh")
211+
212+ keep = currentCB(0)
213+ assert.True(t, keep, "current ticker should keep running while visible")
214+ assert.Equal(t, 1, h.runIdle(), "current ticker schedules refresh")
215+}
216+
217+func TestHistorySidebar_RelativeTimeDayBoundaryRequestsReloadNoRepoCall(t *testing.T) {
218+ h := newSidebarReactivityHarness()
219+ day1 := time.Date(2026, 6, 24, 23, 59, 0, 0, time.UTC)
220+ day2 := day1.Add(2 * time.Minute)
221+ now := day1
222+ h.hs.now = func() time.Time { return now }
223+ h.hs.visible = true
224+ h.hs.relativeTimeDayKey = h.hs.currentDayKey()
225+ h.hs.relativeTimeDayKeySet = true
226+ h.hs.relativeTimeLabelBinds = []relativeTimeLabelBinding{{lastVisited: day1.Add(-time.Hour)}}
227+ now = day2
228+ h.hs.updateRelativeTimeLabelsOnMain()
229+ assert.False(t, h.hs.loadStarted, "day boundary should not reload synchronously")
230+ assert.Equal(t, 1, h.runIdle(), "day boundary schedules debounced reload via RequestReloadIfVisible")
231+ assert.False(t, h.hs.loadStarted, "day boundary should wait for debounce before loading")
232+ assert.Equal(t, h.hs.currentDayKey(), h.hs.relativeTimeDayKey)
233+ assert.Len(t, h.timers, 1)
234+}
235+
236+func TestRelativeTimeAtDeterministic(t *testing.T) {
237+ now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
238+ assert.Equal(t, "1m ago", relativeTimeAt(now.Add(-time.Minute), now))
239+ assert.Equal(t, "2h ago", relativeTimeAt(now.Add(-2*time.Hour), now))
240+}
241+
242+func TestHistorySidebar_RelativeRefreshDoesNotTouchHistoryRepo(t *testing.T) {
243+ h := newSidebarReactivityHarness()
244+ h.hs.historyUC = nil
245+ h.hs.visible = true
246+ h.hs.relativeTimeLabelBinds = []relativeTimeLabelBinding{{lastVisited: h.hs.currentTime().Add(-time.Minute)}}
247+ h.hs.updateRelativeTimeLabelsOnMain()
248+ assert.Empty(t, h.timers, "label-only refresh does not schedule reload/repo work without day boundary")
249+}
250+
251+func TestHistorySidebar_ReloadStillPreservesDisplayRowsSemantics(t *testing.T) {
252+ hs := newTestSidebarSearchHarness()
253+ entry := &entity.HistoryEntry{URL: "https://example.com", LastVisited: time.Now()}
254+ hs.setDisplayGroupsLocked(groupHistoryByDay([]*entity.HistoryEntry{entry}))
255+ require.Len(t, hs.displayRows, 2)
256+ assert.Equal(t, entry.URL, hs.entryURLAtIndex(1))
257+}
M internal/ui/component/history_sidebar_rendering.go +30 -30
@@ -14,62 +14,61 @@ import (
1414 // rebuildList clears and repopulates the list box from current groups.
1515 // Must be called on the GTK main thread. Preserves scroll and selection.
1616 func (hs *HistorySidebar) rebuildList() {
17- hs.mu.RLock()
17+ hs.mu.Lock()
1818 if hs.destroyed || hs.listBox == nil {
19- hs.mu.RUnlock()
19+ hs.mu.Unlock()
2020 return
2121 }
22- rows := hs.displayRows
22+ listBox := hs.listBox
23+ rows := append([]historyDisplayRow(nil), hs.displayRows...)
2324 query := hs.currentQuery
2425 hasSearchResults := hs.searchResults != nil
2526 searchDone := hs.searchDone
2627 totalLoaded := hs.totalLoaded
27- hs.mu.RUnlock()
28+ isLoading := hs.isLoading
29+ hs.clearRelativeTimeBindingsLocked()
30+ hs.mu.Unlock()
2831
2932 // Remove all rows
30- hs.listBox.RemoveAll()
33+ listBox.RemoveAll()
3134
3235 if len(rows) == 0 {
3336 if !hasSearchResults && totalLoaded == 0 {
3437 // Browse has not loaded yet AND no search has completed.
35- hs.showLoadingOrEmpty(query, searchDone)
38+ hs.showLoadingOrEmpty(listBox, query, searchDone, isLoading)
3639 return
3740 }
3841 // Search completed with 0 results, or browse loaded but empty (no history).
39- hs.showEmptyState(query)
40- hs.restoreScrollAndSelection()
42+ hs.showEmptyState(listBox, query)
43+ hs.restoreScrollAndSelectionInListBox(listBox)
4144 return
4245 }
4346
4447 for _, row := range rows {
4548 switch row.Kind {
4649 case historyDisplayRowHeader:
47- hs.appendGroupHeader(row.Label)
50+ hs.appendGroupHeader(listBox, row.Label)
4851 case historyDisplayRowEntry:
49- hs.appendEntryRow(row.Entry)
52+ hs.appendEntryRow(listBox, row.Entry)
5053 }
5154 }
5255
53- hs.listBox.Show()
56+ listBox.Show()
5457
5558 // Restore previous scroll position and selection
56- hs.restoreScrollAndSelection()
59+ hs.restoreScrollAndSelectionInListBox(listBox)
5760
5861 // If no selection was restored and this is the first load, select first entry
59- hs.ensureAtLeastOneSelection()
62+ hs.ensureAtLeastOneSelectionInListBox(listBox)
6063 }
6164
62-func (hs *HistorySidebar) showLoadingOrEmpty(query string, searchDone bool) {
65+func (hs *HistorySidebar) showLoadingOrEmpty(listBox *gtk.ListBox, query string, searchDone, isLoading bool) {
6366 label := gtk.NewLabel(nil)
6467 if label == nil {
6568 return
6669 }
6770 label.AddCssClass("history-sidebar-loading")
6871
69- hs.mu.RLock()
70- isLoading := hs.isLoading
71- hs.mu.RUnlock()
72-
7372 switch {
7473 case isLoading && query == "":
7574 label.SetText("Loading history...")
@@ -92,14 +91,14 @@ func (hs *HistorySidebar) showLoadingOrEmpty(query string, searchDone bool) {
9291 row.SetCanFocus(false)
9392 row.SetActivatable(false)
9493 row.SetChild(&label.Widget)
95- hs.listBox.Append(&row.Widget)
94+ listBox.Append(&row.Widget)
9695 }
9796
9897 func noResultsText(query string) string {
9998 return "No results for \"" + query + "\""
10099 }
101100
102-func (hs *HistorySidebar) showEmptyState(query string) {
101+func (hs *HistorySidebar) showEmptyState(listBox *gtk.ListBox, query string) {
103102 label := gtk.NewLabel(nil)
104103 if label == nil {
105104 return
@@ -123,11 +122,11 @@ func (hs *HistorySidebar) showEmptyState(query string) {
123122 row.SetCanFocus(false)
124123 row.SetActivatable(false)
125124 row.SetChild(&label.Widget)
126- hs.listBox.Append(&row.Widget)
125+ listBox.Append(&row.Widget)
127126 }
128127
129128 // appendGroupHeader adds a non-selectable group header label to the list.
130-func (hs *HistorySidebar) appendGroupHeader(labelText string) {
129+func (hs *HistorySidebar) appendGroupHeader(listBox *gtk.ListBox, labelText string) {
131130 label := gtk.NewLabel(&labelText)
132131 if label == nil {
133132 return
@@ -144,11 +143,11 @@ func (hs *HistorySidebar) appendGroupHeader(labelText string) {
144143 row.SetCanFocus(false)
145144 row.SetActivatable(false)
146145 row.SetChild(&label.Widget)
147- hs.listBox.Append(&row.Widget)
146+ listBox.Append(&row.Widget)
148147 }
149148
150149 // appendEntryRow adds a selectable two-line entry row to the list.
151-func (hs *HistorySidebar) appendEntryRow(entry *entity.HistoryEntry) {
150+func (hs *HistorySidebar) appendEntryRow(listBox *gtk.ListBox, entry *entity.HistoryEntry) {
152151 if entry == nil {
153152 return
154153 }
@@ -192,7 +191,7 @@ func (hs *HistorySidebar) appendEntryRow(entry *entity.HistoryEntry) {
192191 return
193192 }
194193 timeLabel.AddCssClass("history-sidebar-row-time")
195- timeLabel.SetText(relativeTime(entry.LastVisited))
194+ timeLabel.SetText(relativeTimeAt(entry.LastVisited, hs.currentTime()))
196195 timeLabel.SetXalign(1.0)
197196
198197 subBox.Append(&urlLabel.Widget)
@@ -213,21 +212,22 @@ func (hs *HistorySidebar) appendEntryRow(entry *entity.HistoryEntry) {
213212 row.SetFocusOnClick(true)
214213 row.SetChild(&rowBox.Widget)
215214
216- hs.listBox.Append(&row.Widget)
215+ listBox.Append(&row.Widget)
216+ hs.bindRelativeTimeLabel(timeLabel, entry.LastVisited)
217217 }
218218
219219 // ensureAtLeastOneSelection selects the first selectable row if nothing is selected.
220-func (hs *HistorySidebar) ensureAtLeastOneSelection() {
221- if hs.listBox.GetSelectedRow() != nil {
220+func (hs *HistorySidebar) ensureAtLeastOneSelectionInListBox(listBox *gtk.ListBox) {
221+ if listBox == nil || listBox.GetSelectedRow() != nil {
222222 return
223223 }
224224 for i := 0; ; i++ {
225- row := hs.listBox.GetRowAtIndex(i)
225+ row := listBox.GetRowAtIndex(i)
226226 if row == nil {
227227 break
228228 }
229229 if row.GetSelectable() {
230- hs.listBox.SelectRow(row)
230+ listBox.SelectRow(row)
231231 return
232232 }
233233 }
M internal/ui/coordinator/navigation.go +20 -8
@@ -22,6 +22,7 @@ type OmniboxProvider interface {
2222 type NavigationCoordinator struct {
2323 contextProvider func() context.Context
2424 navigateUC *usecase.NavigateUseCase
25+ historyRecorder *usecase.HistoryRecorderUseCase
2526 contentCoord *content.Coordinator
2627 omniboxProvider OmniboxProvider
2728 }
@@ -33,6 +34,16 @@ func NewNavigationCoordinator(
3334 ctx context.Context,
3435 navigateUC *usecase.NavigateUseCase,
3536 contentCoord *content.Coordinator,
37+) *NavigationCoordinator {
38+ return NewNavigationCoordinatorWithHistoryRecorder(ctx, navigateUC, nil, contentCoord)
39+}
40+
41+// NewNavigationCoordinatorWithHistoryRecorder creates a new NavigationCoordinator with history recording.
42+func NewNavigationCoordinatorWithHistoryRecorder(
43+ ctx context.Context,
44+ navigateUC *usecase.NavigateUseCase,
45+ historyRecorder *usecase.HistoryRecorderUseCase,
46+ contentCoord *content.Coordinator,
3647 ) *NavigationCoordinator {
3748 log := logging.FromContext(ctx)
3849 log.Debug().Msg("creating navigation coordinator")
@@ -42,8 +53,9 @@ func NewNavigationCoordinator(
4253 contextProvider: func() context.Context {
4354 return logging.WithContext(context.Background(), callbackLogger)
4455 },
45- navigateUC: navigateUC,
46- contentCoord: contentCoord,
56+ navigateUC: navigateUC,
57+ historyRecorder: historyRecorder,
58+ contentCoord: contentCoord,
4759 }
4860 }
4961
@@ -239,28 +251,28 @@ func (c *NavigationCoordinator) PrintWebView(ctx context.Context, wv port.WebVie
239251
240252 // UpdateHistoryTitle updates the title of a history entry after page load.
241253 func (c *NavigationCoordinator) UpdateHistoryTitle(ctx context.Context, paneID entity.PaneID, url, title string) {
242- if c.navigateUC == nil {
254+ if c.historyRecorder == nil {
243255 return
244256 }
245257
246- c.navigateUC.UpdateHistoryTitle(ctx, url, title)
258+ c.historyRecorder.UpdateHistoryTitle(ctx, url, title)
247259 }
248260
249261 // RecordHistory records a URL in history on page commit.
250262 func (c *NavigationCoordinator) RecordHistory(ctx context.Context, paneID entity.PaneID, url string) {
251- if c.navigateUC == nil {
263+ if c.historyRecorder == nil {
252264 return
253265 }
254266
255- c.navigateUC.RecordHistory(ctx, string(paneID), url)
267+ c.historyRecorder.RecordHistory(ctx, string(paneID), url)
256268 }
257269
258270 // ClearPaneHistory clears per-pane navigation history deduplication state.
259271 func (c *NavigationCoordinator) ClearPaneHistory(paneID entity.PaneID) {
260- if c.navigateUC == nil {
272+ if c.historyRecorder == nil {
261273 return
262274 }
263- c.navigateUC.ClearPaneHistory(string(paneID))
275+ c.historyRecorder.ClearPaneHistory(string(paneID))
264276 }
265277
266278 // NotifyZoomChanged updates the omnibox zoom indicator.
M internal/ui/deps.go +9 -8
@@ -51,14 +51,15 @@ type Dependencies struct {
5151 FilterRepo repository.ContentWhitelistRepository
5252
5353 // Use Cases
54- TabsUC *usecase.ManageTabsUseCase
55- PanesUC *usecase.ManagePanesUseCase
56- NavigateUC *usecase.NavigateUseCase
57- ZoomUC *usecase.ManageZoomUseCase
58- PermissionUC *usecase.HandlePermissionUseCase
59- FavoritesUC *usecase.ManageFavoritesUseCase
60- HistoryUC *usecase.SearchHistoryUseCase
61- CopyURLUC *usecase.CopyURLUseCase
54+ TabsUC *usecase.ManageTabsUseCase
55+ PanesUC *usecase.ManagePanesUseCase
56+ NavigateUC *usecase.NavigateUseCase
57+ HistoryRecorderUC *usecase.HistoryRecorderUseCase
58+ ZoomUC *usecase.ManageZoomUseCase
59+ PermissionUC *usecase.HandlePermissionUseCase
60+ FavoritesUC *usecase.ManageFavoritesUseCase
61+ HistoryUC *usecase.SearchHistoryUseCase
62+ CopyURLUC *usecase.CopyURLUseCase
6263
6364 // Infrastructure Adapters
6465 Clipboard port.Clipboard
A internal/ui/history_changes.go +72 -0
@@ -0,0 +1,72 @@
1+package ui
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/dto"
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/logging"
9+)
10+
11+var _ port.HistoryChangeSink = (*historyChangeAdapter)(nil)
12+
13+type historySidebarReloader interface {
14+ RequestReloadIfVisible(reason string)
15+}
16+
17+type historyChangeAdapter struct {
18+ app *App
19+}
20+
21+func newHistoryChangeAdapter(app *App) *historyChangeAdapter {
22+ return &historyChangeAdapter{app: app}
23+}
24+
25+func (h *historyChangeAdapter) OnHistoryChanged(ctx context.Context, change dto.HistoryChange) {
26+ if h == nil || h.app == nil {
27+ return
28+ }
29+ app := h.app
30+ run := func() {
31+ app.refreshVisibleHistorySidebars(change)
32+ }
33+ if app.dispatchOnMainThread == nil {
34+ logging.FromContext(ctx).Warn().
35+ Msg("history sidebar refresh skipped: main-thread dispatcher unavailable")
36+ return
37+ }
38+ result := app.dispatchOnMainThread("ui.history_sidebar_refresh", run)
39+ if !result.Completed() {
40+ logging.FromContext(ctx).Warn().
41+ Str("status", string(result.Status)).
42+ Dur("elapsed", result.Elapsed).
43+ Msg("history sidebar refresh dispatch did not complete")
44+ }
45+}
46+
47+func (a *App) refreshVisibleHistorySidebars(change dto.HistoryChange) {
48+ if a == nil {
49+ return
50+ }
51+ reason := historyChangeReasonLabel(change)
52+ for _, bw := range a.browserWindows {
53+ if bw == nil || !bw.sidebarVisible {
54+ continue
55+ }
56+ reloader := bw.historySidebarReloader
57+ if reloader == nil {
58+ reloader = bw.historySidebar
59+ }
60+ if reloader == nil {
61+ continue
62+ }
63+ reloader.RequestReloadIfVisible(reason)
64+ }
65+}
66+
67+func historyChangeReasonLabel(change dto.HistoryChange) string {
68+ if len(change.Reasons) == 0 {
69+ return "history-change"
70+ }
71+ return string(change.Reasons[0])
72+}

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