detached 0984239

Merge branch 'main' into chore/dx-verify-generated-check

commit
0984239ff5ac0ff61cb8a2e432e4f65d1dbd598b
author
bnema · · 2026-06-25 06:31 UTC
committer
GitHub · · 2026-06-25 06:31 UTC
merge of
2cb5c80 + b3fd143 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
60 files · +4191 -1105
M .github/workflows/aur.yml +8 -4
@@ -23,7 +23,7 @@ jobs:
2323 cancel-in-progress: true
2424
2525 steps:
26- - uses: actions/checkout@v6
26+ - uses: actions/checkout@v7
2727 with:
2828 ref: ${{ github.event.workflow_run.head_sha }}
2929 fetch-depth: 0
@@ -52,7 +52,7 @@ jobs:
5252 'url="https://github.com/bnema/dumber"' \
5353 "license=('MIT')" \
5454 "makedepends=('go>=1.26' 'git' 'brotli')" \
55- "depends=('gtk4' 'webkitgtk-6.0')" \
55+ "depends=('gtk4' 'cef' 'webkitgtk-6.0')" \
5656 'optdepends=(' \
5757 " 'gst-plugins-base: Base media codecs'" \
5858 " 'gst-plugins-good: Additional media codecs'" \
@@ -61,6 +61,8 @@ jobs:
6161 " 'gst-libav: FFmpeg-based codecs'" \
6262 " 'gst-plugin-pipewire: PipeWire audio support'" \
6363 " 'gst-plugin-va: Hardware video decoding (VA-API stateless decoders)'" \
64+ " 'cef-vaapi-bin: Prebuilt CEF provider with VA-API hardware video decoding support'" \
65+ " 'cef-vaapi: Source-built CEF provider with VA-API hardware video decoding support'" \
6466 " 'pipewire: Audio/video routing'" \
6567 " 'pipewire-pulse: PulseAudio replacement'" \
6668 " 'mesa: VA-API driver for AMD GPUs'" \
@@ -121,7 +123,7 @@ jobs:
121123 cancel-in-progress: true
122124
123125 steps:
124- - uses: actions/checkout@v6
126+ - uses: actions/checkout@v7
125127 with:
126128 fetch-depth: 0
127129
@@ -184,7 +186,7 @@ jobs:
184186 "arch=('x86_64')" \
185187 'url="https://github.com/bnema/dumber"' \
186188 "license=('MIT')" \
187- "depends=('gtk4' 'webkitgtk-6.0')" \
189+ "depends=('gtk4' 'cef' 'webkitgtk-6.0')" \
188190 'optdepends=(' \
189191 " 'gst-plugins-base: Base media codecs'" \
190192 " 'gst-plugins-good: Additional media codecs'" \
@@ -193,6 +195,8 @@ jobs:
193195 " 'gst-libav: FFmpeg-based codecs'" \
194196 " 'gst-plugin-pipewire: PipeWire audio support'" \
195197 " 'gst-plugin-va: Hardware video decoding (VA-API stateless decoders)'" \
198+ " 'cef-vaapi-bin: Prebuilt CEF provider with VA-API hardware video decoding support'" \
199+ " 'cef-vaapi: Source-built CEF provider with VA-API hardware video decoding support'" \
196200 " 'pipewire: Audio/video routing'" \
197201 " 'pipewire-pulse: PulseAudio replacement'" \
198202 " 'mesa: VA-API driver for AMD GPUs'" \
M .github/workflows/ci.yml +13 -8
@@ -5,7 +5,7 @@ permissions:
55
66 on:
77 push:
8- branches: [main]
8+ branches: [main, next]
99 paths:
1010 - '**.go'
1111 - 'go.mod'
@@ -20,15 +20,20 @@ on:
2020 jobs:
2121 changes:
2222 runs-on: ubuntu-latest
23+ permissions:
24+ contents: read
25+ # dorny/paths-filter uses PR metadata to compute changed files.
26+ pull-requests: read
2327 outputs:
2428 ci: ${{ steps.filter.outputs.ci }}
2529 steps:
26- - uses: actions/checkout@v6
30+ - uses: actions/checkout@v7
2731 with:
2832 fetch-depth: 0
2933 - uses: dorny/paths-filter@v4
3034 id: filter
3135 with:
36+ base: ${{ github.event_name == 'push' && github.ref_name || '' }}
3237 filters: |
3338 ci:
3439 - '**.go'
@@ -51,7 +56,7 @@ jobs:
5156 run: |
5257 pacman -Syu --noconfirm
5358 pacman -S --noconfirm git go webkitgtk-6.0 gtk4 gobject-introspection base-devel brotli
54- - uses: actions/checkout@v6
59+ - uses: actions/checkout@v7
5560 - name: Allow git commands in container checkout
5661 run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
5762 - name: Set up pinned Go toolchain
@@ -60,14 +65,14 @@ jobs:
6065 go-version-file: go.mod
6166 cache: false
6267 - name: Cache Go modules
63- uses: actions/cache@v5
68+ uses: actions/cache@v6
6469 with:
6570 path: ~/go/pkg/mod
6671 key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
6772 restore-keys: |
6873 ${{ runner.os }}-go-mod-
6974 - name: Cache Go build cache
70- uses: actions/cache@v5
75+ uses: actions/cache@v6
7176 with:
7277 path: ~/.cache/go-build
7378 key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}
@@ -95,21 +100,21 @@ jobs:
95100 run: |
96101 pacman -Syu --noconfirm
97102 pacman -S --noconfirm git go webkitgtk-6.0 gtk4 libadwaita gobject-introspection base-devel brotli dbus
98- - uses: actions/checkout@v6
103+ - uses: actions/checkout@v7
99104 - name: Set up pinned Go toolchain
100105 uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c
101106 with:
102107 go-version-file: go.mod
103108 cache: false
104109 - name: Cache Go modules
105- uses: actions/cache@v5
110+ uses: actions/cache@v6
106111 with:
107112 path: ~/go/pkg/mod
108113 key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
109114 restore-keys: |
110115 ${{ runner.os }}-go-mod-
111116 - name: Cache Go build cache
112- uses: actions/cache@v5
117+ uses: actions/cache@v6
113118 with:
114119 path: ~/.cache/go-build
115120 key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}
M .github/workflows/flatpak.yml +1 -1
@@ -21,7 +21,7 @@ jobs:
2121 image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-48
2222 options: --privileged
2323 steps:
24- - uses: actions/checkout@v6
24+ - uses: actions/checkout@v7
2525
2626 - name: Get version
2727 id: version
A .github/workflows/release-paths.yml +69 -0
@@ -0,0 +1,69 @@
1+name: Release Paths
2+
3+permissions:
4+ contents: read
5+
6+on:
7+ push:
8+ branches: [main, next]
9+ paths:
10+ - 'docs/**'
11+ - '*.md'
12+ - 'install.sh'
13+ - 'scripts/**'
14+ - 'flatpak/**'
15+ - 'aur/**'
16+ - '.goreleaser.yaml'
17+ - '.github/workflows/aur.yml'
18+ - '.github/workflows/release-paths.yml'
19+ pull_request:
20+ branches: [main, next]
21+ paths:
22+ - 'docs/**'
23+ - '*.md'
24+ - 'install.sh'
25+ - 'scripts/**'
26+ - 'flatpak/**'
27+ - 'aur/**'
28+ - '.goreleaser.yaml'
29+ - '.github/workflows/aur.yml'
30+ - '.github/workflows/release-paths.yml'
31+
32+jobs:
33+ validate:
34+ runs-on: ubuntu-latest
35+ concurrency:
36+ group: release-paths-${{ github.event.pull_request.number || github.ref }}
37+ cancel-in-progress: true
38+ steps:
39+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
40+ with:
41+ persist-credentials: false
42+ - name: Validate shell entrypoints
43+ run: |
44+ shopt -s nullglob globstar
45+ files=(install.sh scripts/**/*.sh aur/*/PKGBUILD)
46+ for f in "${files[@]}"; do
47+ bash -n "$f"
48+ done
49+ - name: Validate release YAML
50+ run: |
51+ ruby -e 'require "psych"; Psych.safe_load_file(".goreleaser.yaml", permitted_classes: [], aliases: true); puts "validated .goreleaser.yaml"'
52+ ruby -e 'require "psych"; Psych.safe_load_file(".github/workflows/aur.yml", permitted_classes: [], aliases: true); puts "validated .github/workflows/aur.yml"'
53+ ruby -e 'require "psych"; Psych.safe_load_file(".github/workflows/release-paths.yml", permitted_classes: [], aliases: true); puts "validated .github/workflows/release-paths.yml"'
54+ - name: Validate Flatpak metadata XML
55+ run: |
56+ python3 - <<'PY'
57+ from pathlib import Path
58+ import xml.etree.ElementTree as ET
59+
60+ for path in Path('flatpak').glob('*.xml'):
61+ ET.parse(path)
62+ print(f'validated {path}')
63+ PY
64+ - name: Check docs and packaging conflict markers
65+ run: |
66+ if grep -RInE '^(<<<<<<<|>>>>>>>)([[:space:]].*)?$' *.md .goreleaser.yaml docs install.sh scripts flatpak aur; then
67+ echo 'Found conflict marker in docs or release-adjacent files' >&2
68+ exit 1
69+ fi
M .github/workflows/release.yml +1 -1
@@ -13,7 +13,7 @@ jobs:
1313 release:
1414 runs-on: ubuntu-latest
1515 steps:
16- - uses: actions/checkout@v6
16+ - uses: actions/checkout@v7
1717 with:
1818 fetch-depth: 0
1919
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 aur/dumber-browser-bin/.SRCINFO +4 -1
@@ -1,11 +1,12 @@
11 pkgbase = dumber-browser-bin
22 pkgdesc = A minimal keyboard-driven browser for tiling WMs
33 pkgver = 0.23.2
4- pkgrel = 1
4+ pkgrel = 2
55 url = https://github.com/bnema/dumber
66 arch = x86_64
77 license = MIT
88 depends = gtk4
9+ depends = cef
910 depends = webkitgtk-6.0
1011 optdepends = gst-plugins-base: Base media codecs
1112 optdepends = gst-plugins-good: Additional media codecs
@@ -14,6 +15,8 @@ pkgbase = dumber-browser-bin
1415 optdepends = gst-libav: FFmpeg-based codecs
1516 optdepends = gst-plugin-pipewire: PipeWire audio support
1617 optdepends = gst-plugin-va: Hardware video decoding (VA-API stateless decoders)
18+ optdepends = cef-vaapi-bin: Prebuilt CEF provider with VA-API hardware video decoding support
19+ optdepends = cef-vaapi: Source-built CEF provider with VA-API hardware video decoding support
1720 optdepends = pipewire: Audio/video routing
1821 optdepends = pipewire-pulse: PulseAudio replacement
1922 optdepends = mesa: VA-API driver for AMD GPUs
M aur/dumber-browser-bin/PKGBUILD +4 -2
@@ -1,12 +1,12 @@
11 # Maintainer: bnema <b at bnema dot dev>
22 pkgname=dumber-browser-bin
33 pkgver=0.23.2
4-pkgrel=1
4+pkgrel=2
55 pkgdesc="A minimal keyboard-driven browser for tiling WMs"
66 arch=('x86_64')
77 url="https://github.com/bnema/dumber"
88 license=('MIT')
9-depends=('gtk4' 'webkitgtk-6.0')
9+depends=('gtk4' 'cef' 'webkitgtk-6.0')
1010 optdepends=(
1111 'gst-plugins-base: Base media codecs'
1212 'gst-plugins-good: Additional media codecs'
@@ -15,6 +15,8 @@ optdepends=(
1515 'gst-libav: FFmpeg-based codecs'
1616 'gst-plugin-pipewire: PipeWire audio support'
1717 'gst-plugin-va: Hardware video decoding (VA-API stateless decoders)'
18+ 'cef-vaapi-bin: Prebuilt CEF provider with VA-API hardware video decoding support'
19+ 'cef-vaapi: Source-built CEF provider with VA-API hardware video decoding support'
1820 'pipewire: Audio/video routing'
1921 'pipewire-pulse: PulseAudio replacement'
2022 'mesa: VA-API driver for AMD GPUs'
M aur/dumber-browser-git/.SRCINFO +2 -1
@@ -18,7 +18,8 @@ pkgbase = dumber-browser-git
1818 optdepends = gst-libav: FFmpeg-based codecs
1919 optdepends = gst-plugin-pipewire: PipeWire audio support
2020 optdepends = gst-plugin-va: Hardware video decoding (VA-API stateless decoders)
21- optdepends = cef-vaapi: Optional AUR CEF build with VA-API hardware video decoding support
21+ optdepends = cef-vaapi-bin: Prebuilt CEF provider with VA-API hardware video decoding support
22+ optdepends = cef-vaapi: Source-built CEF provider with VA-API hardware video decoding support
2223 optdepends = pipewire: Audio/video routing
2324 optdepends = pipewire-pulse: PulseAudio replacement
2425 optdepends = mesa: VA-API driver for AMD GPUs
M aur/dumber-browser-git/PKGBUILD +2 -1
@@ -16,7 +16,8 @@ optdepends=(
1616 'gst-libav: FFmpeg-based codecs'
1717 'gst-plugin-pipewire: PipeWire audio support'
1818 'gst-plugin-va: Hardware video decoding (VA-API stateless decoders)'
19- 'cef-vaapi: Optional AUR CEF build with VA-API hardware video decoding support'
19+ 'cef-vaapi-bin: Prebuilt CEF provider with VA-API hardware video decoding support'
20+ 'cef-vaapi: Source-built CEF provider with VA-API hardware video decoding support'
2021 'pipewire: Audio/video routing'
2122 'pipewire-pulse: PulseAudio replacement'
2223 'mesa: VA-API driver for AMD GPUs'
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),
M cmd/systemviews/bootstrap_test.go +1 -1
@@ -157,7 +157,7 @@ func (f *bridgeServiceRecorder) TimelineByDomain(context.Context, string, int, i
157157 return f.historyEntries, nil
158158 }
159159
160-func (f *bridgeServiceRecorder) TimelineWindow(_ context.Context, before time.Time, _ string) (*entity.HistoryWindow, error) {
160+func (f *bridgeServiceRecorder) TimelineWindow(_ context.Context, before time.Time, _ int64, _ string) (*entity.HistoryWindow, error) {
161161 f.calledHistory.Store(true)
162162 return &entity.HistoryWindow{Entries: f.historyEntries, Before: before, After: before.Add(-24 * time.Hour)}, nil
163163 }
M go.mod +2 -2
@@ -16,13 +16,13 @@ require (
1616 github.com/charmbracelet/lipgloss v1.1.0
1717 github.com/fsnotify/fsnotify v1.10.1
1818 github.com/godbus/dbus/v5 v5.2.2
19- github.com/pelletier/go-toml/v2 v2.4.0
19+ github.com/pelletier/go-toml/v2 v2.4.2
2020 github.com/pressly/goose/v3 v3.27.1
2121 github.com/rs/zerolog v1.35.1
2222 github.com/spf13/cobra v1.10.2
2323 github.com/spf13/viper v1.21.0
2424 github.com/stretchr/testify v1.11.1
25- golang.org/x/image v0.42.0
25+ golang.org/x/image v0.43.0
2626 golang.org/x/net v0.56.0
2727 golang.org/x/sys v0.46.0
2828 gopkg.in/yaml.v3 v3.0.1
M go.sum +4 -4 generated file: collapsed
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+}
M internal/application/port/homepage.go +1 -1
@@ -12,7 +12,7 @@ import (
1212 type HomepageHistory interface {
1313 GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
1414 GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error)
15- GetRecentWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error)
15+ GetRecentWindow(ctx context.Context, before time.Time, beforeID int64, domain string) (*entity.HistoryWindow, error)
1616 Search(ctx context.Context, input dto.HistorySearchInput) (*dto.HistorySearchOutput, error)
1717 Delete(ctx context.Context, id int64) error
1818 ClearRange(ctx context.Context, rangeID string) error
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+}
M internal/application/port/mocks/homepage.go +20 -14
@@ -604,8 +604,8 @@ func (_c *MockHomepageHistory_GetRecentByDomain_Call) RunAndReturn(run func(ctx
604604 }
605605
606606 // GetRecentWindow provides a mock function for the type MockHomepageHistory
607-func (_mock *MockHomepageHistory) GetRecentWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) {
608- ret := _mock.Called(ctx, before, domain)
607+func (_mock *MockHomepageHistory) GetRecentWindow(ctx context.Context, before time.Time, beforeID int64, domain string) (*entity.HistoryWindow, error) {
608+ ret := _mock.Called(ctx, before, beforeID, domain)
609609
610610 if len(ret) == 0 {
611611 panic("no return value specified for GetRecentWindow")
@@ -613,18 +613,18 @@ func (_mock *MockHomepageHistory) GetRecentWindow(ctx context.Context, before ti
613613
614614 var r0 *entity.HistoryWindow
615615 var r1 error
616- if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, string) (*entity.HistoryWindow, error)); ok {
617- return returnFunc(ctx, before, domain)
616+ if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, int64, string) (*entity.HistoryWindow, error)); ok {
617+ return returnFunc(ctx, before, beforeID, domain)
618618 }
619- if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, string) *entity.HistoryWindow); ok {
620- r0 = returnFunc(ctx, before, domain)
619+ if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, int64, string) *entity.HistoryWindow); ok {
620+ r0 = returnFunc(ctx, before, beforeID, domain)
621621 } else {
622622 if ret.Get(0) != nil {
623623 r0 = ret.Get(0).(*entity.HistoryWindow)
624624 }
625625 }
626- if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time, string) error); ok {
627- r1 = returnFunc(ctx, before, domain)
626+ if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time, int64, string) error); ok {
627+ r1 = returnFunc(ctx, before, beforeID, domain)
628628 } else {
629629 r1 = ret.Error(1)
630630 }
@@ -639,12 +639,13 @@ type MockHomepageHistory_GetRecentWindow_Call struct {
639639 // GetRecentWindow is a helper method to define mock.On call
640640 // - ctx context.Context
641641 // - before time.Time
642+// - beforeID int64
642643 // - domain string
643-func (_e *MockHomepageHistory_Expecter) GetRecentWindow(ctx any, before any, domain any) *MockHomepageHistory_GetRecentWindow_Call {
644- return &MockHomepageHistory_GetRecentWindow_Call{Call: _e.mock.On("GetRecentWindow", ctx, before, domain)}
644+func (_e *MockHomepageHistory_Expecter) GetRecentWindow(ctx any, before any, beforeID any, domain any) *MockHomepageHistory_GetRecentWindow_Call {
645+ return &MockHomepageHistory_GetRecentWindow_Call{Call: _e.mock.On("GetRecentWindow", ctx, before, beforeID, domain)}
645646 }
646647
647-func (_c *MockHomepageHistory_GetRecentWindow_Call) Run(run func(ctx context.Context, before time.Time, domain string)) *MockHomepageHistory_GetRecentWindow_Call {
648+func (_c *MockHomepageHistory_GetRecentWindow_Call) Run(run func(ctx context.Context, before time.Time, beforeID int64, domain string)) *MockHomepageHistory_GetRecentWindow_Call {
648649 _c.Call.Run(func(args mock.Arguments) {
649650 var arg0 context.Context
650651 if args[0] != nil {
@@ -654,14 +655,19 @@ func (_c *MockHomepageHistory_GetRecentWindow_Call) Run(run func(ctx context.Con
654655 if args[1] != nil {
655656 arg1 = args[1].(time.Time)
656657 }
657- var arg2 string
658+ var arg2 int64
658659 if args[2] != nil {
659- arg2 = args[2].(string)
660+ arg2 = args[2].(int64)
661+ }
662+ var arg3 string
663+ if args[3] != nil {
664+ arg3 = args[3].(string)
660665 }
661666 run(
662667 arg0,
663668 arg1,
664669 arg2,
670+ arg3,
665671 )
666672 })
667673 return _c
@@ -672,7 +678,7 @@ func (_c *MockHomepageHistory_GetRecentWindow_Call) Return(historyWindow *entity
672678 return _c
673679 }
674680
675-func (_c *MockHomepageHistory_GetRecentWindow_Call) RunAndReturn(run func(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error)) *MockHomepageHistory_GetRecentWindow_Call {
681+func (_c *MockHomepageHistory_GetRecentWindow_Call) RunAndReturn(run func(ctx context.Context, before time.Time, beforeID int64, domain string) (*entity.HistoryWindow, error)) *MockHomepageHistory_GetRecentWindow_Call {
676682 _c.Call.Return(run)
677683 return _c
678684 }
M internal/application/port/systemviews.go +1 -1
@@ -12,7 +12,7 @@ import (
1212 type SystemviewHistoryService interface {
1313 Timeline(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
1414 TimelineByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error)
15- TimelineWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error)
15+ TimelineWindow(ctx context.Context, before time.Time, beforeID int64, domain string) (*entity.HistoryWindow, error)
1616 Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error)
1717 DeleteEntry(ctx context.Context, id int64) error
1818 DeleteRange(ctx context.Context, rangeID string) error
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 +798 -0
@@ -0,0 +1,798 @@
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+ // historyWorkerShutdownFlushTimeout bounds final persistence attempts during shutdown.
29+ historyWorkerShutdownFlushTimeout = 2 * time.Second
30+
31+ // historyWorkerShutdownWaitTimeout bounds Close even if a repository call ignores context cancellation.
32+ historyWorkerShutdownWaitTimeout = historyWorkerShutdownFlushTimeout + 500*time.Millisecond
33+)
34+
35+// historyDeduplicationWindow is the time window for deduplicating history visits.
36+// Visits to the same URL within this window count as a single visit.
37+// This prevents inflation from redirects and rapid navigation.
38+const historyDeduplicationWindow = 2 * time.Second
39+
40+// stripTrackingParamsForHistoryDedup controls whether known tracking parameters
41+// are removed while canonicalizing URLs for history deduplication/storage.
42+const stripTrackingParamsForHistoryDedup = true
43+
44+// historyRecord holds data for async history recording.
45+type historyRecord struct {
46+ url string
47+ title string // non-empty for title-update-only records
48+ visits int
49+}
50+
51+type historyVisitDeltaIncrementer interface {
52+ IncrementVisitCountBy(ctx context.Context, url string, delta int) error
53+}
54+
55+type historyMetadataUpdater interface {
56+ UpdateMetadata(ctx context.Context, entry *entity.HistoryEntry) error
57+}
58+
59+type paneHistoryState struct {
60+ lastRawURL string
61+ lastCanonicalURL string
62+ lastRecordedAt time.Time
63+}
64+
65+type historyCanonicalizationOptions struct {
66+ StripTrackingParams bool
67+}
68+
69+type historyControlRequest struct {
70+ kind historyControlKind
71+ ctx context.Context
72+ done chan error
73+}
74+
75+type historyControlKind int
76+
77+type titlePersistenceResult int
78+
79+const historyControlFlushAndReset historyControlKind = iota
80+
81+const (
82+ titlePersistenceRetry titlePersistenceResult = iota
83+ titlePersistenceSaved
84+ titlePersistenceDiscard
85+)
86+
87+var (
88+ errHistoryRecorderClosing = errors.New("history recorder closing")
89+ errHistoryPersistenceIncomplete = errors.New("history persistence incomplete")
90+)
91+
92+type pendingHistoryRecords struct {
93+ visits map[string]int
94+ titles map[string]string
95+}
96+
97+func newPendingHistoryRecords() *pendingHistoryRecords {
98+ return &pendingHistoryRecords{
99+ visits: make(map[string]int),
100+ titles: make(map[string]string),
101+ }
102+}
103+
104+func (p *pendingHistoryRecords) add(record historyRecord) {
105+ if record.title != "" {
106+ p.titles[record.url] = record.title
107+ }
108+ if record.visits > 0 {
109+ p.visits[record.url] += record.visits
110+ }
111+}
112+
113+func (p *pendingHistoryRecords) empty() bool {
114+ return len(p.visits) == 0 && len(p.titles) == 0
115+}
116+
117+func (p *pendingHistoryRecords) reset() {
118+ p.visits = make(map[string]int)
119+ p.titles = make(map[string]string)
120+}
121+
122+// HistoryRecorderUseCase records persisted history visits and title updates asynchronously.
123+type HistoryRecorderUseCase struct {
124+ historyRepo repository.HistoryRepository
125+ changeSinkMu sync.RWMutex
126+ changeSink port.HistoryChangeSink
127+ recentMu sync.Mutex
128+ recentVisits map[string]paneHistoryState // key: pane ID, value: per-pane history state
129+ mutationMu sync.Mutex
130+ closeOnce sync.Once
131+ enqueueMu sync.Mutex
132+ closing bool
133+
134+ historyQueue chan historyRecord
135+ controlQueue chan historyControlRequest
136+ done chan struct{}
137+ wg sync.WaitGroup
138+ ctx context.Context // Base context for background worker
139+ cancel context.CancelFunc
140+}
141+
142+// NewHistoryRecorderUseCase creates a new history recorder use case.
143+func NewHistoryRecorderUseCase(historyRepo repository.HistoryRepository, changeSink port.HistoryChangeSink) *HistoryRecorderUseCase {
144+ changeSink = normalizeHistoryChangeSink(changeSink)
145+ ctx, cancel := context.WithCancel(context.Background())
146+
147+ uc := &HistoryRecorderUseCase{
148+ historyRepo: historyRepo,
149+ changeSink: changeSink,
150+ recentVisits: make(map[string]paneHistoryState),
151+ historyQueue: make(chan historyRecord, historyQueueSize),
152+ controlQueue: make(chan historyControlRequest),
153+ done: make(chan struct{}),
154+ ctx: ctx,
155+ cancel: cancel,
156+ }
157+
158+ uc.wg.Add(1)
159+ go uc.historyWorker()
160+
161+ return uc
162+}
163+
164+// SetHistoryChangeSink sets the sink for persisted history change notifications.
165+func (uc *HistoryRecorderUseCase) SetHistoryChangeSink(changeSink port.HistoryChangeSink) {
166+ if uc == nil {
167+ return
168+ }
169+ changeSink = normalizeHistoryChangeSink(changeSink)
170+ uc.changeSinkMu.Lock()
171+ uc.changeSink = changeSink
172+ uc.changeSinkMu.Unlock()
173+}
174+
175+// Close shuts down the background history worker and drains any pending records.
176+func (uc *HistoryRecorderUseCase) Close() {
177+ if uc == nil {
178+ return
179+ }
180+ uc.closeOnce.Do(func() {
181+ uc.enqueueMu.Lock()
182+ uc.closing = true
183+ uc.enqueueMu.Unlock()
184+ uc.cancelWorkerContext()
185+ if uc.done != nil {
186+ close(uc.done)
187+ }
188+ })
189+ if uc.waitForHistoryWorker(historyWorkerShutdownWaitTimeout) {
190+ return
191+ }
192+ logging.FromContext(uc.workerContext()).Warn().
193+ Dur("timeout", historyWorkerShutdownWaitTimeout).
194+ Msg("history worker shutdown timed out")
195+}
196+
197+func (uc *HistoryRecorderUseCase) workerContext() context.Context {
198+ if uc == nil || uc.ctx == nil {
199+ return context.Background()
200+ }
201+ return uc.ctx
202+}
203+
204+func (uc *HistoryRecorderUseCase) cancelWorkerContext() {
205+ if uc != nil && uc.cancel != nil {
206+ uc.cancel()
207+ }
208+}
209+
210+func (uc *HistoryRecorderUseCase) waitForHistoryWorker(timeout time.Duration) bool {
211+ done := make(chan struct{})
212+ go func() {
213+ uc.wg.Wait()
214+ close(done)
215+ }()
216+ if timeout <= 0 {
217+ <-done
218+ return true
219+ }
220+ timer := time.NewTimer(timeout)
221+ defer timer.Stop()
222+ select {
223+ case <-done:
224+ return true
225+ case <-timer.C:
226+ return false
227+ }
228+}
229+
230+// RecordHistory queues a history entry for async recording.
231+// This is non-blocking to avoid SQLite I/O on the GTK main thread.
232+// Should be called on LoadCommitted when URI is guaranteed correct.
233+func (uc *HistoryRecorderUseCase) RecordHistory(ctx context.Context, paneID, rawURL string) {
234+ if uc == nil {
235+ return
236+ }
237+ log := logging.FromContext(ctx)
238+ rawURL = strings.TrimSpace(rawURL)
239+ if rawURL == "" {
240+ return
241+ }
242+
243+ if !isRecordableHistoryURL(rawURL) {
244+ return
245+ }
246+
247+ canonicalURL := canonicalizeURLForHistory(rawURL, historyCanonicalizationOptions{
248+ StripTrackingParams: stripTrackingParamsForHistoryDedup,
249+ })
250+ if canonicalURL == "" {
251+ return
252+ }
253+
254+ paneID = normalizePaneID(paneID)
255+
256+ now := time.Now()
257+
258+ uc.mutationMu.Lock()
259+ defer uc.mutationMu.Unlock()
260+
261+ uc.recentMu.Lock()
262+ state := uc.recentVisits[paneID]
263+
264+ // Ignore in-page hash transitions for history persistence.
265+ if isHashOnlyTransition(state.lastRawURL, rawURL) {
266+ state.lastRawURL = rawURL
267+ uc.recentVisits[paneID] = state
268+ uc.recentMu.Unlock()
269+ return
270+ }
271+
272+ // Per-pane short-window deduplication by canonical URL.
273+ if state.lastCanonicalURL == canonicalURL && now.Sub(state.lastRecordedAt) < historyDeduplicationWindow {
274+ state.lastRawURL = rawURL
275+ uc.recentVisits[paneID] = state
276+ uc.recentMu.Unlock()
277+ return
278+ }
279+
280+ state.lastRawURL = rawURL
281+ state.lastCanonicalURL = canonicalURL
282+ state.lastRecordedAt = now
283+ enqueued := uc.enqueueHistoryRecord(historyRecord{url: canonicalURL, visits: 1})
284+ if enqueued {
285+ uc.recentVisits[paneID] = state
286+ }
287+ uc.recentMu.Unlock()
288+
289+ if !enqueued {
290+ log.Warn().Str("url", logging.RedactURL(canonicalURL)).Msg("history queue full or recorder closed, dropping record")
291+ }
292+}
293+
294+// ClearPaneHistory removes per-pane deduplication state to prevent unbounded growth.
295+// Callers should invoke this when a pane is closed.
296+func (uc *HistoryRecorderUseCase) ClearPaneHistory(paneID string) {
297+ if uc == nil {
298+ return
299+ }
300+ paneID = normalizePaneID(paneID)
301+ uc.mutationMu.Lock()
302+ defer uc.mutationMu.Unlock()
303+ uc.recentMu.Lock()
304+ delete(uc.recentVisits, paneID)
305+ uc.recentMu.Unlock()
306+}
307+
308+// UpdateHistoryTitle queues a title update for a history entry. The actual
309+// DB write happens in the background historyWorker to avoid concurrent
310+// repo access from the GTK main thread and the worker goroutine.
311+func (uc *HistoryRecorderUseCase) UpdateHistoryTitle(_ context.Context, historyURL, title string) {
312+ if uc == nil {
313+ return
314+ }
315+ historyURL = strings.TrimSpace(historyURL)
316+ if historyURL == "" || !isRecordableHistoryURL(historyURL) {
317+ return
318+ }
319+ historyURL = canonicalizeURLForHistory(historyURL, historyCanonicalizationOptions{
320+ StripTrackingParams: stripTrackingParamsForHistoryDedup,
321+ })
322+ if historyURL == "" {
323+ return
324+ }
325+
326+ uc.mutationMu.Lock()
327+ defer uc.mutationMu.Unlock()
328+
329+ if !uc.enqueueHistoryRecord(historyRecord{url: historyURL, title: title}) {
330+ logging.FromContext(uc.workerContext()).Warn().
331+ Str("url", logging.RedactURL(historyURL)).
332+ Msg("history queue full or recorder closed, title update dropped")
333+ }
334+}
335+
336+func (uc *HistoryRecorderUseCase) enqueueHistoryRecord(record historyRecord) bool {
337+ uc.enqueueMu.Lock()
338+ defer uc.enqueueMu.Unlock()
339+ if uc.closing {
340+ return false
341+ }
342+ select {
343+ case uc.historyQueue <- record:
344+ return true
345+ default:
346+ return false
347+ }
348+}
349+
350+// historyWorker is a background goroutine that processes history records.
351+// It drains the queue and persists records to the database without blocking the UI.
352+func (uc *HistoryRecorderUseCase) historyWorker() {
353+ defer uc.wg.Done()
354+
355+ ticker := time.NewTicker(historyWorkerFlushInterval)
356+ defer ticker.Stop()
357+ workerCtx := uc.workerContext()
358+
359+ pending := newPendingHistoryRecords()
360+ for {
361+ select {
362+ case record := <-uc.historyQueue:
363+ pending.add(record)
364+ case <-ticker.C:
365+ _ = uc.flushPendingHistory(workerCtx, pending)
366+ case req := <-uc.controlQueue:
367+ uc.handleHistoryControl(pending, req)
368+ case <-uc.done:
369+ uc.shutdownHistoryWorker(pending)
370+ return
371+ case <-workerCtx.Done():
372+ uc.shutdownHistoryWorker(pending)
373+ return
374+ }
375+ }
376+}
377+
378+func (uc *HistoryRecorderUseCase) flushPendingHistory(ctx context.Context, pending *pendingHistoryRecords) error {
379+ if ctx == nil {
380+ ctx = uc.workerContext()
381+ }
382+ if pending.empty() {
383+ return nil
384+ }
385+
386+ visitCount := len(pending.visits)
387+ titleCount := len(pending.titles)
388+ logging.FromContext(ctx).Debug().
389+ Int("visits", visitCount).
390+ Int("titles", titleCount).
391+ Msg("flushing pending history")
392+
393+ var flushErr error
394+ successfulVisits := 0
395+ for historyURL, visits := range pending.visits {
396+ if err := ctx.Err(); err != nil {
397+ uc.publishHistoryChange(successfulVisits, 0)
398+ return err
399+ }
400+ record := historyRecord{url: historyURL, visits: visits}
401+ writtenVisits := uc.persistHistory(ctx, record)
402+ if writtenVisits > 0 {
403+ successfulVisits += writtenVisits
404+ if writtenVisits >= visits {
405+ delete(pending.visits, historyURL)
406+ } else {
407+ pending.visits[historyURL] = visits - writtenVisits
408+ }
409+ }
410+ if writtenVisits < visits {
411+ flushErr = errHistoryPersistenceIncomplete
412+ }
413+ }
414+
415+ successfulTitles := 0
416+ for historyURL, title := range pending.titles {
417+ if err := ctx.Err(); err != nil {
418+ uc.publishHistoryChange(successfulVisits, successfulTitles)
419+ return err
420+ }
421+ if _, visitStillPending := pending.visits[historyURL]; visitStillPending {
422+ flushErr = errHistoryPersistenceIncomplete
423+ continue
424+ }
425+ switch uc.persistTitleUpdate(ctx, historyURL, title) {
426+ case titlePersistenceSaved:
427+ successfulTitles++
428+ delete(pending.titles, historyURL)
429+ case titlePersistenceDiscard:
430+ delete(pending.titles, historyURL)
431+ case titlePersistenceRetry:
432+ flushErr = errHistoryPersistenceIncomplete
433+ }
434+ }
435+ uc.publishHistoryChange(successfulVisits, successfulTitles)
436+ if flushErr != nil {
437+ return flushErr
438+ }
439+ return ctx.Err()
440+}
441+
442+func (uc *HistoryRecorderUseCase) handleHistoryControl(pending *pendingHistoryRecords, req historyControlRequest) {
443+ var err error
444+ switch req.kind {
445+ case historyControlFlushAndReset:
446+ uc.drainHistoryQueue(pending)
447+ err = uc.flushPendingHistory(req.ctx, pending)
448+ if err == nil {
449+ uc.resetRecentVisits()
450+ }
451+ }
452+ req.done <- err
453+ close(req.done)
454+}
455+
456+func (uc *HistoryRecorderUseCase) drainHistoryQueue(pending *pendingHistoryRecords) {
457+ for {
458+ select {
459+ case record := <-uc.historyQueue:
460+ pending.add(record)
461+ default:
462+ return
463+ }
464+ }
465+}
466+
467+func (uc *HistoryRecorderUseCase) shutdownHistoryWorker(pending *pendingHistoryRecords) {
468+ log := logging.FromContext(uc.workerContext()).With().
469+ Str("component", "history-worker").
470+ Logger()
471+ shutdownCtx, cancel := context.WithTimeout(context.Background(), historyWorkerShutdownFlushTimeout)
472+ defer cancel()
473+
474+ for {
475+ if err := shutdownCtx.Err(); err != nil {
476+ if !pending.empty() {
477+ log.Warn().Err(err).Msg("dropping unflushed history during shutdown after timeout")
478+ pending.reset()
479+ }
480+ return
481+ }
482+ log.Debug().Int("remaining", len(uc.historyQueue)).Msg("draining history queue")
483+ uc.drainHistoryQueue(pending)
484+ uc.flushPendingHistoryForShutdown(shutdownCtx, log, pending)
485+
486+ uc.enqueueMu.Lock()
487+ uc.drainHistoryQueue(pending)
488+ if pending.empty() && len(uc.historyQueue) == 0 {
489+ uc.closing = true
490+ uc.enqueueMu.Unlock()
491+ log.Debug().Msg("history worker shutdown complete")
492+ return
493+ }
494+ uc.enqueueMu.Unlock()
495+ }
496+}
497+
498+func (uc *HistoryRecorderUseCase) flushPendingHistoryForShutdown(ctx context.Context, log zerolog.Logger, pending *pendingHistoryRecords) {
499+ const shutdownFlushAttempts = 3
500+ for attempt := 1; attempt <= shutdownFlushAttempts; attempt++ {
501+ err := uc.flushPendingHistory(ctx, pending)
502+ if err == nil || pending.empty() {
503+ return
504+ }
505+ if attempt == shutdownFlushAttempts {
506+ log.Warn().Err(err).Msg("dropping unflushed history during shutdown after retries")
507+ pending.reset()
508+ return
509+ }
510+ log.Warn().Err(err).Int("attempt", attempt).Msg("retrying unflushed history during shutdown")
511+ select {
512+ case <-ctx.Done():
513+ log.Warn().Err(ctx.Err()).Msg("dropping unflushed history during shutdown after timeout")
514+ pending.reset()
515+ return
516+ case <-time.After(historyWorkerFlushInterval):
517+ }
518+ }
519+}
520+
521+// BeginHistoryMutation flushes pending recorder writes and blocks new recorder enqueues until release is called.
522+func (uc *HistoryRecorderUseCase) BeginHistoryMutation(ctx context.Context) (func(), error) {
523+ if uc == nil {
524+ return func() {}, nil
525+ }
526+ if ctx == nil {
527+ ctx = context.Background()
528+ }
529+ uc.mutationMu.Lock()
530+ if err := uc.flushAndResetHistory(ctx); err != nil {
531+ uc.mutationMu.Unlock()
532+ if errors.Is(err, errHistoryRecorderClosing) {
533+ uc.wg.Wait()
534+ uc.resetRecentVisits()
535+ return func() {}, nil
536+ }
537+ return nil, err
538+ }
539+ return func() { uc.mutationMu.Unlock() }, nil
540+}
541+
542+func (uc *HistoryRecorderUseCase) flushAndResetHistory(ctx context.Context) error {
543+ req := historyControlRequest{
544+ kind: historyControlFlushAndReset,
545+ ctx: ctx,
546+ done: make(chan error, 1),
547+ }
548+ select {
549+ case uc.controlQueue <- req:
550+ select {
551+ case err := <-req.done:
552+ return err
553+ case <-uc.done:
554+ return uc.waitForAcceptedFlushOrShutdown(ctx, req.done)
555+ case <-ctx.Done():
556+ return ctx.Err()
557+ }
558+ case <-uc.done:
559+ return errHistoryRecorderClosing
560+ case <-ctx.Done():
561+ return ctx.Err()
562+ }
563+}
564+
565+func (uc *HistoryRecorderUseCase) waitForAcceptedFlushOrShutdown(ctx context.Context, reqDone <-chan error) error {
566+ wgDone := make(chan struct{})
567+ go func() {
568+ uc.wg.Wait()
569+ close(wgDone)
570+ }()
571+
572+ select {
573+ case err := <-reqDone:
574+ return err
575+ case <-wgDone:
576+ uc.resetRecentVisits()
577+ return errHistoryRecorderClosing
578+ case <-ctx.Done():
579+ return ctx.Err()
580+ }
581+}
582+
583+func (uc *HistoryRecorderUseCase) resetRecentVisits() {
584+ uc.recentMu.Lock()
585+ uc.recentVisits = make(map[string]paneHistoryState)
586+ uc.recentMu.Unlock()
587+}
588+
589+func (uc *HistoryRecorderUseCase) publishHistoryChange(successfulVisits, successfulTitles int) {
590+ if successfulVisits == 0 && successfulTitles == 0 {
591+ return
592+ }
593+ reasons := make([]dto.HistoryChangeReason, 0, 2)
594+ if successfulVisits > 0 {
595+ reasons = append(reasons, dto.HistoryChangeReasonVisit)
596+ }
597+ if successfulTitles > 0 {
598+ reasons = append(reasons, dto.HistoryChangeReasonTitle)
599+ }
600+ sink := uc.historyChangeSink()
601+ sink.OnHistoryChanged(uc.workerContext(), dto.HistoryChange{
602+ Reasons: reasons,
603+ VisitCount: successfulVisits,
604+ TitleCount: successfulTitles,
605+ ChangedAt: time.Now(),
606+ })
607+}
608+
609+func (uc *HistoryRecorderUseCase) historyChangeSink() port.HistoryChangeSink {
610+ uc.changeSinkMu.RLock()
611+ sink := uc.changeSink
612+ uc.changeSinkMu.RUnlock()
613+ return normalizeHistoryChangeSink(sink)
614+}
615+
616+// persistHistory writes a history record to the database.
617+// Called from the background worker goroutine.
618+func (uc *HistoryRecorderUseCase) persistHistory(ctx context.Context, record historyRecord) int {
619+ log := logging.FromContext(ctx)
620+
621+ existing, err := uc.historyRepo.FindByURL(ctx, record.url)
622+ if err != nil {
623+ log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Msg("failed to check history")
624+ return 0
625+ }
626+
627+ delta := max(1, record.visits)
628+ if existing != nil {
629+ if deltaWriter, ok := uc.historyRepo.(historyVisitDeltaIncrementer); ok {
630+ if err := deltaWriter.IncrementVisitCountBy(ctx, record.url, delta); err != nil {
631+ log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Int("delta", delta).Msg("failed to increment visit count by delta")
632+ return 0
633+ }
634+ return delta
635+ }
636+ written := 0
637+ for i := 0; i < delta; i++ {
638+ if err := uc.historyRepo.IncrementVisitCount(ctx, record.url); err != nil {
639+ log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Msg("failed to increment visit count")
640+ return written
641+ }
642+ written++
643+ }
644+ return written
645+ }
646+
647+ entry := entity.NewHistoryEntry(record.url, "")
648+ entry.VisitCount = int64(delta)
649+ if err := uc.historyRepo.Save(ctx, entry); err != nil {
650+ log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Msg("failed to save history")
651+ return 0
652+ }
653+ return delta
654+}
655+
656+// persistTitleUpdate writes a title update to the database.
657+// Called from the background worker goroutine.
658+func (uc *HistoryRecorderUseCase) persistTitleUpdate(ctx context.Context, historyURL, title string) titlePersistenceResult {
659+ log := logging.FromContext(ctx)
660+
661+ if title == "" {
662+ return titlePersistenceDiscard
663+ }
664+
665+ entry, err := uc.historyRepo.FindByURL(ctx, historyURL)
666+ if err != nil {
667+ log.Warn().Err(err).Str("url", logging.RedactURL(historyURL)).Msg("failed to find history entry for title update")
668+ return titlePersistenceRetry
669+ }
670+ if entry == nil {
671+ return titlePersistenceDiscard
672+ }
673+
674+ entry.Title = title
675+ if updater, ok := uc.historyRepo.(historyMetadataUpdater); ok {
676+ if err := updater.UpdateMetadata(ctx, entry); err != nil {
677+ log.Warn().Err(err).Str("url", logging.RedactURL(historyURL)).Msg("failed to update history metadata")
678+ return titlePersistenceRetry
679+ }
680+ return titlePersistenceSaved
681+ }
682+ if err := uc.historyRepo.Save(ctx, entry); err != nil {
683+ log.Warn().Err(err).Str("url", logging.RedactURL(historyURL)).Msg("failed to update history title")
684+ return titlePersistenceRetry
685+ }
686+ return titlePersistenceSaved
687+}
688+
689+func isRecordableHistoryURL(raw string) bool {
690+ parsed, err := url.Parse(strings.TrimSpace(raw))
691+ if err != nil {
692+ return false
693+ }
694+ if strings.TrimSpace(parsed.Host) == "" {
695+ return false
696+ }
697+ switch strings.ToLower(parsed.Scheme) {
698+ case "http", "https":
699+ return true
700+ default:
701+ return false
702+ }
703+}
704+
705+func canonicalizeURLForHistory(raw string, opts historyCanonicalizationOptions) string {
706+ raw = strings.TrimSpace(raw)
707+ if raw == "" {
708+ return ""
709+ }
710+
711+ parsed, err := url.Parse(raw)
712+ if err != nil {
713+ return strings.TrimSuffix(raw, "/")
714+ }
715+
716+ parsed.Scheme = strings.ToLower(parsed.Scheme)
717+ parsed.Host = canonicalHistoryHost(parsed)
718+ parsed.User = nil
719+ parsed.Fragment = ""
720+ parsed.Path = normalizePathForHistory(parsed.Path)
721+
722+ query := parsed.Query()
723+ if opts.StripTrackingParams {
724+ for key := range query {
725+ if isTrackingQueryParam(key) {
726+ query.Del(key)
727+ }
728+ }
729+ }
730+ parsed.RawQuery = query.Encode()
731+
732+ return parsed.String()
733+}
734+
735+func canonicalHistoryHost(parsed *url.URL) string {
736+ host := strings.ToLower(parsed.Hostname())
737+ portValue := parsed.Port()
738+ if portValue == "" || (parsed.Scheme == "http" && portValue == "80") || (parsed.Scheme == "https" && portValue == "443") {
739+ if strings.Contains(host, ":") {
740+ return "[" + host + "]"
741+ }
742+ return host
743+ }
744+ return net.JoinHostPort(host, portValue)
745+}
746+
747+func normalizePathForHistory(path string) string {
748+ if path == "/" {
749+ return ""
750+ }
751+ if strings.HasSuffix(path, "/") {
752+ return strings.TrimSuffix(path, "/")
753+ }
754+ return path
755+}
756+
757+func isTrackingQueryParam(key string) bool {
758+ key = strings.ToLower(strings.TrimSpace(key))
759+ if key == "" {
760+ return false
761+ }
762+ if strings.HasPrefix(key, "utm_") {
763+ return true
764+ }
765+ switch key {
766+ case "fbclid", "gclid", "msclkid", "dclid", "yclid", "mc_cid", "mc_eid", "_hsenc", "_hsmi", "igshid":
767+ return true
768+ }
769+ return false
770+}
771+
772+func isHashOnlyTransition(previous, current string) bool {
773+ if previous == "" || current == "" || previous == current {
774+ return false
775+ }
776+
777+ prevParsed, prevErr := url.Parse(previous)
778+ currParsed, currErr := url.Parse(current)
779+ if prevErr != nil || currErr != nil {
780+ return false
781+ }
782+
783+ if prevParsed.Fragment == currParsed.Fragment {
784+ return false
785+ }
786+
787+ opts := historyCanonicalizationOptions{StripTrackingParams: stripTrackingParamsForHistoryDedup}
788+ prevCanonical := canonicalizeURLForHistory(previous, opts)
789+ currCanonical := canonicalizeURLForHistory(current, opts)
790+ return prevCanonical != "" && prevCanonical == currCanonical
791+}
792+
793+func normalizePaneID(paneID string) string {
794+ if strings.TrimSpace(paneID) == "" {
795+ return "__default__"
796+ }
797+ return paneID
798+}
A internal/application/usecase/history_recorder_test.go +650 -0
@@ -0,0 +1,650 @@
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_CloseCancelsInFlightWorkerFlushAndDrains(t *testing.T) {
208+ ctx := context.Background()
209+ sink := &recordingHistoryChangeSink{}
210+ repo := repomocks.NewMockHistoryRepository(t)
211+ const historyURL = "https://example.com/cancel-close"
212+
213+ findStarted := make(chan struct{})
214+ var findOnce sync.Once
215+ repo.EXPECT().FindByURL(mock.Anything, historyURL).RunAndReturn(
216+ func(ctx context.Context, _ string) (*entity.HistoryEntry, error) {
217+ first := false
218+ findOnce.Do(func() { first = true })
219+ if first {
220+ close(findStarted)
221+ <-ctx.Done()
222+ return nil, ctx.Err()
223+ }
224+ return nil, nil
225+ },
226+ ).Twice()
227+ repo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(entry *entity.HistoryEntry) bool {
228+ return entry.URL == historyURL && entry.VisitCount == 1
229+ })).Return(nil).Once()
230+
231+ uc := NewHistoryRecorderUseCase(repo, sink)
232+ uc.RecordHistory(ctx, "pane-1", historyURL)
233+
234+ select {
235+ case <-findStarted:
236+ case <-time.After(time.Second):
237+ t.Fatal("timed out waiting for periodic history flush to start")
238+ }
239+
240+ closed := make(chan struct{})
241+ go func() {
242+ uc.Close()
243+ close(closed)
244+ }()
245+
246+ select {
247+ case <-closed:
248+ case <-time.After(time.Second):
249+ t.Fatal("timed out waiting for history recorder shutdown")
250+ }
251+
252+ changes := sink.snapshot()
253+ require.Len(t, changes, 1)
254+ require.Equal(t, 1, changes[0].VisitCount)
255+}
256+
257+func TestHistoryRecorder_FailedPersistencePublishesNoChange(t *testing.T) {
258+ ctx := context.Background()
259+ sink := &recordingHistoryChangeSink{}
260+ repo := repomocks.NewMockHistoryRepository(t)
261+
262+ repo.EXPECT().FindByURL(mock.Anything, "https://example.com/fail").Return(nil, errors.New("db down")).Times(3)
263+
264+ uc := NewHistoryRecorderUseCase(repo, sink)
265+ uc.RecordHistory(ctx, "pane-1", "https://example.com/fail")
266+ uc.Close()
267+
268+ require.Empty(t, sink.snapshot())
269+}
270+
271+func TestHistoryRecorder_RecordHistory_DoesNotUpdateDedupeStateWhenEnqueueFails(t *testing.T) {
272+ ctx := context.Background()
273+ uc := &HistoryRecorderUseCase{
274+ recentVisits: make(map[string]paneHistoryState),
275+ historyQueue: make(chan historyRecord, 1),
276+ }
277+ uc.historyQueue <- historyRecord{url: "https://queued.example", visits: 1}
278+
279+ uc.RecordHistory(ctx, "pane-1", "https://example.com/full")
280+
281+ uc.recentMu.Lock()
282+ _, ok := uc.recentVisits["pane-1"]
283+ uc.recentMu.Unlock()
284+ require.False(t, ok, "failed enqueue must not suppress a later retry through dedupe state")
285+}
286+
287+func TestHistoryRecorder_UpdateHistoryTitle_PublishesTitleOnlyWithoutIncrementingVisits(t *testing.T) {
288+ ctx := context.Background()
289+ sink := &recordingHistoryChangeSink{}
290+ repo := &metadataUpdatingHistoryRepository{
291+ MockHistoryRepository: repomocks.NewMockHistoryRepository(t),
292+ entry: &entity.HistoryEntry{
293+ URL: "https://example.com/article",
294+ Title: "Old",
295+ VisitCount: 7,
296+ },
297+ }
298+ repo.EXPECT().FindByURL(mock.Anything, "https://example.com/article").RunAndReturn(
299+ func(context.Context, string) (*entity.HistoryEntry, error) {
300+ return repo.snapshotEntry(), nil
301+ },
302+ ).Once()
303+
304+ uc := NewHistoryRecorderUseCase(repo, sink)
305+ uc.UpdateHistoryTitle(ctx, "https://example.com/article", "New")
306+ uc.Close()
307+
308+ after := repo.snapshotEntry()
309+ require.NotNil(t, after)
310+ require.Equal(t, "New", after.Title)
311+ require.Equal(t, int64(7), after.VisitCount)
312+
313+ changes := sink.snapshot()
314+ require.Len(t, changes, 1)
315+ require.Equal(t, []dto.HistoryChangeReason{dto.HistoryChangeReasonTitle}, changes[0].Reasons)
316+ require.Zero(t, changes[0].VisitCount)
317+ require.Equal(t, 1, changes[0].TitleCount)
318+}
319+
320+func TestHistoryRecorder_FlushPendingHistoryDiscardsTitleWhenHistoryRowMissing(t *testing.T) {
321+ ctx := context.Background()
322+ sink := &recordingHistoryChangeSink{}
323+ repo := repomocks.NewMockHistoryRepository(t)
324+ const historyURL = "https://example.com/missing-title"
325+
326+ repo.EXPECT().FindByURL(mock.Anything, historyURL).Return(nil, nil).Once()
327+
328+ uc := &HistoryRecorderUseCase{historyRepo: repo, changeSink: sink, ctx: ctx}
329+ pending := newPendingHistoryRecords()
330+ pending.add(historyRecord{url: historyURL, title: "Missing"})
331+
332+ err := uc.flushPendingHistory(ctx, pending)
333+
334+ require.NoError(t, err)
335+ require.Empty(t, pending.visits)
336+ require.Empty(t, pending.titles)
337+ require.Empty(t, sink.snapshot())
338+}
339+
340+func TestHistoryRecorder_RecordHistory_IgnoresHashOnlyTransitionsAndPublishesNoSecondChange(t *testing.T) {
341+ ctx := context.Background()
342+ sink := &recordingHistoryChangeSink{}
343+ repo := repomocks.NewMockHistoryRepository(t)
344+
345+ repo.EXPECT().FindByURL(mock.Anything, "https://example.com/docs").Return(nil, nil).Once()
346+ repo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(entry *entity.HistoryEntry) bool {
347+ return entry.URL == "https://example.com/docs" && entry.VisitCount == 1
348+ })).Return(nil).Once()
349+
350+ uc := NewHistoryRecorderUseCase(repo, sink)
351+ uc.RecordHistory(ctx, "pane-1", "https://example.com/docs#intro")
352+ uc.RecordHistory(ctx, "pane-1", "https://example.com/docs#api")
353+ uc.Close()
354+
355+ changes := sink.snapshot()
356+ require.Len(t, changes, 1)
357+ require.Equal(t, 1, changes[0].VisitCount)
358+}
359+
360+func TestHistoryRecorder_RecordHistory_DedupIsPerPane(t *testing.T) {
361+ ctx := context.Background()
362+ sink := &recordingHistoryChangeSink{}
363+ repo := repomocks.NewMockHistoryRepository(t)
364+
365+ repo.EXPECT().FindByURL(mock.Anything, "https://example.com/dedupe").Return(nil, nil).Once()
366+ repo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(entry *entity.HistoryEntry) bool {
367+ return entry.URL == "https://example.com/dedupe" && entry.VisitCount == 2
368+ })).Return(nil).Once()
369+
370+ uc := NewHistoryRecorderUseCase(repo, sink)
371+ uc.RecordHistory(ctx, "pane-1", "https://example.com/dedupe")
372+ uc.RecordHistory(ctx, "pane-1", "https://example.com/dedupe")
373+ uc.RecordHistory(ctx, "pane-2", "https://example.com/dedupe")
374+ uc.Close()
375+
376+ changes := sink.snapshot()
377+ require.Len(t, changes, 1)
378+ require.Equal(t, 2, changes[0].VisitCount)
379+}
380+
381+func TestHistoryRecorder_ClearPaneHistorySerializesWithRecorderAndAllowsPaneReuse(t *testing.T) {
382+ ctx := context.Background()
383+ sink := &recordingHistoryChangeSink{}
384+ repo := repomocks.NewMockHistoryRepository(t)
385+ const historyURL = "https://example.com/reused-pane"
386+
387+ repo.EXPECT().FindByURL(mock.Anything, historyURL).Return(nil, nil).Once()
388+ repo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(entry *entity.HistoryEntry) bool {
389+ return entry.URL == historyURL && entry.VisitCount == 2
390+ })).Return(nil).Once()
391+
392+ uc := NewHistoryRecorderUseCase(repo, sink)
393+ uc.RecordHistory(ctx, "pane-1", historyURL)
394+ uc.ClearPaneHistory("pane-1")
395+ uc.RecordHistory(ctx, "pane-1", historyURL)
396+ uc.Close()
397+
398+ changes := sink.snapshot()
399+ require.Len(t, changes, 1)
400+ require.Equal(t, 2, changes[0].VisitCount)
401+}
402+
403+func TestHistoryRecorder_FlushPendingHistoryTracksPartialFallbackVisitWrites(t *testing.T) {
404+ ctx := context.Background()
405+ sink := &recordingHistoryChangeSink{}
406+ repo := repomocks.NewMockHistoryRepository(t)
407+ const historyURL = "https://example.com/partial"
408+ increments := 0
409+
410+ repo.EXPECT().FindByURL(mock.Anything, historyURL).Return(&entity.HistoryEntry{URL: historyURL}, nil).Once()
411+ repo.EXPECT().IncrementVisitCount(mock.Anything, historyURL).RunAndReturn(func(context.Context, string) error {
412+ increments++
413+ if increments == 3 {
414+ return errors.New("increment failed")
415+ }
416+ return nil
417+ }).Times(3)
418+
419+ uc := &HistoryRecorderUseCase{historyRepo: repo, changeSink: sink, ctx: ctx}
420+ pending := newPendingHistoryRecords()
421+ pending.add(historyRecord{url: historyURL, visits: 3})
422+
423+ err := uc.flushPendingHistory(ctx, pending)
424+
425+ require.ErrorIs(t, err, errHistoryPersistenceIncomplete)
426+ require.Equal(t, map[string]int{historyURL: 1}, pending.visits)
427+ require.Empty(t, pending.titles)
428+ changes := sink.snapshot()
429+ require.Len(t, changes, 1)
430+ require.Equal(t, 2, changes[0].VisitCount)
431+}
432+
433+func TestHistoryRecorder_HandleHistoryControlKeepsDedupeWhenFlushFails(t *testing.T) {
434+ ctx := context.Background()
435+ repo := repomocks.NewMockHistoryRepository(t)
436+ const historyURL = "https://example.com/fail-reset"
437+
438+ repo.EXPECT().FindByURL(mock.Anything, historyURL).Return(nil, errors.New("db down")).Once()
439+
440+ uc := &HistoryRecorderUseCase{
441+ historyRepo: repo,
442+ changeSink: &recordingHistoryChangeSink{},
443+ recentVisits: map[string]paneHistoryState{
444+ "pane-1": {lastCanonicalURL: historyURL},
445+ },
446+ ctx: ctx,
447+ }
448+ pending := newPendingHistoryRecords()
449+ pending.add(historyRecord{url: historyURL, visits: 1})
450+ req := historyControlRequest{kind: historyControlFlushAndReset, ctx: ctx, done: make(chan error, 1)}
451+
452+ uc.handleHistoryControl(pending, req)
453+
454+ require.ErrorIs(t, <-req.done, errHistoryPersistenceIncomplete)
455+ require.Contains(t, uc.recentVisits, "pane-1")
456+}
457+
458+func TestHistoryRecorder_BeginHistoryMutationFlushesPendingAndResetsDedupe(t *testing.T) {
459+ ctx := context.Background()
460+ sink := &recordingHistoryChangeSink{}
461+ repo := repomocks.NewMockHistoryRepository(t)
462+
463+ repo.EXPECT().FindByURL(mock.Anything, "https://example.com/reset").Return(nil, nil).Twice()
464+ repo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(entry *entity.HistoryEntry) bool {
465+ return entry.URL == "https://example.com/reset" && entry.VisitCount == 1
466+ })).Return(nil).Twice()
467+
468+ uc := NewHistoryRecorderUseCase(repo, sink)
469+ uc.RecordHistory(ctx, "pane-1", "https://example.com/reset")
470+
471+ release, err := uc.BeginHistoryMutation(ctx)
472+ require.NoError(t, err)
473+ release()
474+
475+ uc.RecordHistory(ctx, "pane-1", "https://example.com/reset")
476+ uc.Close()
477+
478+ changes := sink.snapshot()
479+ require.Len(t, changes, 2)
480+ require.Equal(t, 1, changes[0].VisitCount)
481+ require.Equal(t, 1, changes[1].VisitCount)
482+}
483+
484+func TestHistoryRecorder_BeginHistoryMutationUsesCallerContextForFlush(t *testing.T) {
485+ type contextKey string
486+ ctx := context.WithValue(context.Background(), contextKey("mutation"), "flush")
487+ sink := &recordingHistoryChangeSink{}
488+ repo := repomocks.NewMockHistoryRepository(t)
489+
490+ repo.EXPECT().FindByURL(mock.Anything, "https://example.com/context").RunAndReturn(
491+ func(ctx context.Context, _ string) (*entity.HistoryEntry, error) {
492+ require.Equal(t, "flush", ctx.Value(contextKey("mutation")))
493+ return nil, nil
494+ },
495+ ).Once()
496+ repo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(entry *entity.HistoryEntry) bool {
497+ return entry.URL == "https://example.com/context" && entry.VisitCount == 1
498+ })).RunAndReturn(func(ctx context.Context, _ *entity.HistoryEntry) error {
499+ require.Equal(t, "flush", ctx.Value(contextKey("mutation")))
500+ return nil
501+ }).Once()
502+
503+ uc := NewHistoryRecorderUseCase(repo, sink)
504+ uc.RecordHistory(context.Background(), "pane-1", "https://example.com/context")
505+
506+ release, err := uc.BeginHistoryMutation(ctx)
507+ require.NoError(t, err)
508+ release()
509+ uc.Close()
510+}
511+
512+func TestHistoryRecorder_BeginHistoryMutationWaitsForInFlightShutdownDrain(t *testing.T) {
513+ ctx := context.Background()
514+ saveStarted := make(chan struct{})
515+ allowSave := make(chan struct{})
516+ repo := repomocks.NewMockHistoryRepository(t)
517+
518+ repo.EXPECT().FindByURL(mock.Anything, "https://example.com/shutdown").Return(nil, nil).Once()
519+ repo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(entry *entity.HistoryEntry) bool {
520+ return entry.URL == "https://example.com/shutdown" && entry.VisitCount == 1
521+ })).RunAndReturn(func(context.Context, *entity.HistoryEntry) error {
522+ close(saveStarted)
523+ <-allowSave
524+ return nil
525+ }).Once()
526+
527+ uc := NewHistoryRecorderUseCase(repo, nil)
528+ uc.RecordHistory(ctx, "pane-1", "https://example.com/shutdown")
529+
530+ closeDone := make(chan struct{})
531+ go func() {
532+ uc.Close()
533+ close(closeDone)
534+ }()
535+ select {
536+ case <-saveStarted:
537+ case <-time.After(time.Second):
538+ t.Fatal("timed out waiting for history save to start")
539+ }
540+
541+ mutationDone := make(chan error, 1)
542+ beginStarted := make(chan struct{})
543+ go func() {
544+ close(beginStarted)
545+ release, err := uc.BeginHistoryMutation(ctx)
546+ if release != nil {
547+ release()
548+ }
549+ mutationDone <- err
550+ }()
551+ select {
552+ case <-beginStarted:
553+ case <-time.After(time.Second):
554+ t.Fatal("timed out waiting for history mutation to start")
555+ }
556+
557+ select {
558+ case err := <-mutationDone:
559+ t.Fatalf("history mutation returned before shutdown drain finished: %v", err)
560+ case <-time.After(50 * time.Millisecond):
561+ }
562+
563+ close(allowSave)
564+ select {
565+ case err := <-mutationDone:
566+ require.NoError(t, err)
567+ case <-time.After(time.Second):
568+ t.Fatal("timed out waiting for history mutation to finish")
569+ }
570+ select {
571+ case <-closeDone:
572+ case <-time.After(time.Second):
573+ t.Fatal("timed out waiting for history recorder shutdown")
574+ }
575+}
576+
577+func TestHistoryWorker_ReenqueueDuringFinalShutdownFlushIsRejected(t *testing.T) {
578+ assertReenqueueDuringFlush(t, false, "")
579+}
580+
581+func TestHistoryWorker_ReenqueueDuringPeriodicFlushIsPersisted(t *testing.T) {
582+ assertReenqueueDuringFlush(t, true, "Queued title")
583+}
584+
585+func assertReenqueueDuringFlush(t *testing.T, waitForPeriodicFlush bool, expectedTitle string) {
586+ t.Helper()
587+ ctx := context.Background()
588+
589+ var mu sync.Mutex
590+ store := make(map[string]*entity.HistoryEntry)
591+ saved := make(chan string, 4)
592+ var saveOnce sync.Once
593+ var uc *HistoryRecorderUseCase
594+
595+ repo := repomocks.NewMockHistoryRepository(t)
596+
597+ repo.EXPECT().FindByURL(mock.Anything, mock.Anything).RunAndReturn(
598+ func(_ context.Context, url string) (*entity.HistoryEntry, error) {
599+ mu.Lock()
600+ defer mu.Unlock()
601+ e := store[url]
602+ if e == nil {
603+ return nil, nil
604+ }
605+ clone := *e
606+ return &clone, nil
607+ },
608+ ).Maybe()
609+
610+ repo.EXPECT().Save(mock.Anything, mock.Anything).RunAndReturn(
611+ func(_ context.Context, entry *entity.HistoryEntry) error {
612+ clone := *entry
613+ mu.Lock()
614+ store[entry.URL] = &clone
615+ mu.Unlock()
616+
617+ saveOnce.Do(func() {
618+ if entry.URL == "https://example.com/article" {
619+ uc.UpdateHistoryTitle(ctx, entry.URL, "Queued title")
620+ }
621+ })
622+ select {
623+ case saved <- entry.URL:
624+ default:
625+ }
626+ return nil
627+ },
628+ ).Maybe()
629+
630+ uc = NewHistoryRecorderUseCase(repo, nil)
631+ uc.RecordHistory(ctx, "pane-1", "https://example.com/article")
632+ if waitForPeriodicFlush {
633+ require.Eventually(t, func() bool {
634+ select {
635+ case url := <-saved:
636+ return url == "https://example.com/article"
637+ default:
638+ return false
639+ }
640+ }, time.Second, 10*time.Millisecond, "periodic flush should save the initial visit before Close")
641+ }
642+ uc.Close()
643+
644+ mu.Lock()
645+ entry := store["https://example.com/article"]
646+ mu.Unlock()
647+ require.NotNil(t, entry)
648+ require.Equal(t, int64(1), entry.VisitCount)
649+ require.Equal(t, expectedTitle, entry.Title)
650+}
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 +172 -18
@@ -4,6 +4,7 @@ import (
44 "context"
55 "fmt"
66 "strings"
7+ "sync"
78 "time"
89
910 "github.com/bnema/dumber/internal/application/dto"
@@ -22,6 +23,7 @@ var _ port.HistorySidebarHistory = (*SearchHistoryUseCase)(nil)
2223
2324 const (
2425 historyWindowDuration = 24 * time.Hour
26+ historyWindowRowLimit = 100
2527
2628 defaultHistoryPageLimit = 50
2729 maxHistoryPageLimit = 500
@@ -33,14 +35,48 @@ const (
3335
3436 // SearchHistoryUseCase handles history search and retrieval operations.
3537 type SearchHistoryUseCase struct {
36- historyRepo repository.HistoryRepository
38+ historyRepo repository.HistoryRepository
39+ changeSinkMu sync.RWMutex
40+ changeSink port.HistoryChangeSink
41+ mutationCoordinatorMu sync.RWMutex
42+ mutationCoordinator port.HistoryMutationCoordinator
3743 }
3844
3945 // NewSearchHistoryUseCase creates a new history search use case.
40-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+
4152 return &SearchHistoryUseCase{
4253 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
62+ }
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
4376 }
77+ uc.mutationCoordinatorMu.Lock()
78+ uc.mutationCoordinator = coordinator
79+ uc.mutationCoordinatorMu.Unlock()
4480 }
4581
4682 // SearchInput is an alias for dto.HistorySearchInput.
@@ -115,17 +151,30 @@ func canonicalHistoryDomain(domain string) (string, error) {
115151 return domain, nil
116152 }
117153
118-// GetRecentWindow retrieves the 24-hour history window ending before the given cursor.
119-func (uc *SearchHistoryUseCase) GetRecentWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) {
120- if before.IsZero() {
154+// GetRecentWindow retrieves a row-bounded history window ending before the given cursor.
155+func (uc *SearchHistoryUseCase) GetRecentWindow(
156+ ctx context.Context,
157+ before time.Time,
158+ beforeID int64,
159+ domain string,
160+) (*entity.HistoryWindow, error) {
161+ if beforeID < 0 {
162+ return nil, fmt.Errorf("beforeID must be non-negative")
163+ }
164+ hasBefore := !before.IsZero()
165+ hasBeforeID := beforeID != 0
166+ if hasBefore != hasBeforeID {
167+ return nil, fmt.Errorf("before and beforeID must be provided together")
168+ }
169+ if !hasBefore {
121170 before = time.Now()
122171 }
123172 before = before.UTC()
124173 after := before.Add(-historyWindowDuration)
174+ queryLimit := historyWindowRowLimit + 1
125175
126176 var (
127177 entries []*entity.HistoryEntry
128- hasMore bool
129178 err error
130179 )
131180 originalDomain := domain
@@ -134,25 +183,35 @@ func (uc *SearchHistoryUseCase) GetRecentWindow(ctx context.Context, before time
134183 return nil, fmt.Errorf("domain is required")
135184 }
136185 if domain != "" {
137- entries, err = uc.historyRepo.GetRecentWindowByDomain(ctx, domain, before, after)
138- if err == nil {
139- hasMore, err = uc.historyRepo.HasEntriesByDomainBefore(ctx, domain, after)
140- }
186+ entries, err = uc.historyRepo.GetRecentWindowByDomain(ctx, domain, before, beforeID, queryLimit)
141187 } else {
142- entries, err = uc.historyRepo.GetRecentWindow(ctx, before, after)
143- if err == nil {
144- hasMore, err = uc.historyRepo.HasEntriesBefore(ctx, after)
145- }
188+ entries, err = uc.historyRepo.GetRecentWindow(ctx, before, beforeID, queryLimit)
146189 }
147190 if err != nil {
148191 return nil, fmt.Errorf("failed to get recent history window: %w", err)
149192 }
150193
194+ hasMore := len(entries) > historyWindowRowLimit
195+ if hasMore {
196+ entries = entries[:historyWindowRowLimit]
197+ }
198+ var cursorLastVisited time.Time
199+ var cursorID int64
200+ if len(entries) > 0 {
201+ last := entries[len(entries)-1]
202+ if last != nil {
203+ cursorLastVisited = last.LastVisited.UTC()
204+ cursorID = last.ID
205+ }
206+ }
207+
151208 return &entity.HistoryWindow{
152- Entries: entries,
153- Before: before,
154- After: after,
155- HasMore: hasMore,
209+ Entries: entries,
210+ Before: before,
211+ After: after,
212+ CursorLastVisited: cursorLastVisited,
213+ CursorID: cursorID,
214+ HasMore: hasMore,
156215 }, nil
157216 }
158217
@@ -228,10 +287,19 @@ func (uc *SearchHistoryUseCase) ClearOlderThan(ctx context.Context, before time.
228287 log := logging.FromContext(ctx)
229288 log.Debug().Time("before", before).Msg("clearing old history")
230289
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+
231297 if err := uc.historyRepo.DeleteOlderThan(ctx, before); err != nil {
232298 return fmt.Errorf("failed to clear history: %w", err)
233299 }
300+ release()
234301
302+ uc.publishHistoryChange(ctx, dto.HistoryChangeReasonClear, 0, false)
235303 log.Info().Time("before", before).Msg("old history cleared")
236304 return nil
237305 }
@@ -248,9 +316,18 @@ func (uc *SearchHistoryUseCase) ClearRange(ctx context.Context, rangeID string)
248316
249317 log := logging.FromContext(ctx)
250318 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+
251326 if err := uc.historyRepo.DeleteSince(ctx, cutoff); err != nil {
252327 return fmt.Errorf("failed to clear history range: %w", err)
253328 }
329+ release()
330+ uc.publishHistoryChange(ctx, dto.HistoryChangeReasonClear, 0, false)
254331 log.Info().Time("since", cutoff).Str("range", rangeID).Msg("recent history range cleared")
255332 return nil
256333 }
@@ -260,10 +337,19 @@ func (uc *SearchHistoryUseCase) ClearAll(ctx context.Context) error {
260337 log := logging.FromContext(ctx)
261338 log.Debug().Msg("clearing all history")
262339
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+
263347 if err := uc.historyRepo.DeleteAll(ctx); err != nil {
264348 return fmt.Errorf("failed to clear all history: %w", err)
265349 }
350+ release()
266351
352+ uc.publishHistoryChange(ctx, dto.HistoryChangeReasonClear, 0, false)
267353 log.Info().Msg("all history cleared")
268354 return nil
269355 }
@@ -273,10 +359,19 @@ func (uc *SearchHistoryUseCase) Delete(ctx context.Context, id int64) error {
273359 log := logging.FromContext(ctx)
274360 log.Debug().Int64("id", id).Msg("deleting history entry")
275361
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+
276369 if err := uc.historyRepo.Delete(ctx, id); err != nil {
277370 return fmt.Errorf("failed to delete history entry: %w", err)
278371 }
372+ release()
279373
374+ uc.publishHistoryChange(ctx, dto.HistoryChangeReasonDelete, 1, true)
280375 log.Debug().Int64("id", id).Msg("history entry deleted")
281376 return nil
282377 }
@@ -291,14 +386,73 @@ func (uc *SearchHistoryUseCase) DeleteByDomain(ctx context.Context, domain strin
291386 log := logging.FromContext(ctx)
292387 log.Debug().Str("domain", domain).Msg("deleting history by domain")
293388
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+
294396 if err := uc.historyRepo.DeleteByDomain(ctx, domain); err != nil {
295397 return fmt.Errorf("failed to delete history by domain: %w", err)
296398 }
399+ release()
297400
401+ uc.publishHistoryChange(ctx, dto.HistoryChangeReasonDelete, 0, false)
298402 log.Info().Str("domain", domain).Msg("history deleted for domain")
299403 return nil
300404 }
301405
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+
302456 // GetDomainStats retrieves per-domain visit statistics.
303457 func (uc *SearchHistoryUseCase) GetDomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error) {
304458 log := logging.FromContext(ctx)
M internal/application/usecase/search_history_test.go +511 -10
@@ -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)
@@ -68,13 +114,56 @@ func TestSearchHistoryUseCase_GetRecentWindow_InvalidDomainReturnsValidationErro
68114 historyRepo := repomocks.NewMockHistoryRepository(t)
69115 uc := usecase.NewSearchHistoryUseCase(historyRepo)
70116
71- result, err := uc.GetRecentWindow(ctx, time.Now(), "///")
117+ result, err := uc.GetRecentWindow(ctx, time.Time{}, 0, "///")
72118
73119 require.Error(t, err)
74120 assert.Nil(t, result)
75121 assert.Contains(t, err.Error(), "domain is required")
76122 }
77123
124+func TestSearchHistoryUseCase_GetRecentWindow_RejectsInvalidCursorPairs(t *testing.T) {
125+ ctx := testContext()
126+ historyRepo := repomocks.NewMockHistoryRepository(t)
127+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
128+
129+ result, err := uc.GetRecentWindow(ctx, time.Time{}, 42, "")
130+ require.Error(t, err)
131+ assert.Nil(t, result)
132+ assert.Contains(t, err.Error(), "before and beforeID")
133+
134+ result, err = uc.GetRecentWindow(ctx, time.Now(), 0, "")
135+ require.Error(t, err)
136+ assert.Nil(t, result)
137+ assert.Contains(t, err.Error(), "before and beforeID")
138+
139+ result, err = uc.GetRecentWindow(ctx, time.Now(), -1, "")
140+ require.Error(t, err)
141+ assert.Nil(t, result)
142+ assert.Contains(t, err.Error(), "beforeID must be non-negative")
143+}
144+
145+func TestSearchHistoryUseCase_GetRecentWindow_UsesLimitPlusOneForHasMoreAndCursor(t *testing.T) {
146+ ctx := testContext()
147+ before := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
148+ entries := make([]*entity.HistoryEntry, 101)
149+ for i := range entries {
150+ entries[i] = &entity.HistoryEntry{ID: int64(200 - i), URL: "https://example.com", LastVisited: before.Add(-time.Duration(i) * time.Minute)}
151+ }
152+ historyRepo := repomocks.NewMockHistoryRepository(t)
153+ historyRepo.EXPECT().GetRecentWindow(mock.Anything, before, int64(999), 101).Return(entries, nil).Once()
154+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
155+
156+ result, err := uc.GetRecentWindow(ctx, before, 999, "")
157+
158+ require.NoError(t, err)
159+ require.NotNil(t, result)
160+ assert.Len(t, result.Entries, 100)
161+ assert.True(t, result.HasMore)
162+ assert.Equal(t, entries[99].LastVisited.UTC(), result.CursorLastVisited)
163+ assert.Equal(t, entries[99].ID, result.CursorID)
164+ assert.Equal(t, before.Add(-24*time.Hour), result.After)
165+}
166+
78167 func TestSearchHistoryUseCase_GetRecentSince_ReturnsEntries(t *testing.T) {
79168 ctx := testContext()
80169
@@ -226,7 +315,388 @@ func TestSearchHistoryUseCase_GetDomainStats_ClampsOversizedLimit(t *testing.T)
226315 assert.Empty(t, result)
227316 }
228317
229-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) {
230700 ctx := testContext()
231701 historyRepo := repomocks.NewMockHistoryRepository(t)
232702 now := time.Now()
@@ -234,11 +704,26 @@ func TestSearchHistoryUseCase_ClearRange_RecentRangeDeletesSinceCutoff(t *testin
234704 historyRepo.EXPECT().DeleteSince(mock.Anything, mock.MatchedBy(func(since time.Time) bool {
235705 return since.After(expectedCutoff.Add(-time.Minute)) && since.Before(expectedCutoff.Add(time.Minute))
236706 })).Return(nil).Once()
237-
238- uc := usecase.NewSearchHistoryUseCase(historyRepo)
707+ sink := &recordingHistoryChangeSink{}
708+ uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
239709
240710 err := uc.ClearRange(ctx, "hour")
711+
241712 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)
242727 }
243728
244729 func TestSearchHistoryUseCase_ClearRange_TodayDeletesSinceLocalMidnight(t *testing.T) {
@@ -256,29 +741,45 @@ func TestSearchHistoryUseCase_ClearRange_TodayDeletesSinceLocalMidnight(t *testi
256741 require.NoError(t, err)
257742 }
258743
259-func TestSearchHistoryUseCase_ClearRange_AllUsesClearAll(t *testing.T) {
744+func TestSearchHistoryUseCase_ClearRange_AllUsesClearAllAndPublishesOnce(t *testing.T) {
260745 ctx := testContext()
261746
262747 historyRepo := repomocks.NewMockHistoryRepository(t)
263748
264- historyRepo.EXPECT().DeleteAll(mock.Anything).Return(nil)
265-
266- uc := usecase.NewSearchHistoryUseCase(historyRepo)
749+ historyRepo.EXPECT().DeleteAll(mock.Anything).Return(nil).Once()
750+ sink := &recordingHistoryChangeSink{}
751+ uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
267752
268753 err := uc.ClearRange(ctx, "all")
269754 require.NoError(t, err)
755+ requirePublishedHistoryChange(t, sink, dto.HistoryChangeReasonClear)
270756 }
271757
272-func TestSearchHistoryUseCase_ClearRange_UnknownRangeDoesNotDelete(t *testing.T) {
758+func TestSearchHistoryUseCase_ClearRange_AllDoesNotPublishOnRepositoryError(t *testing.T) {
273759 ctx := testContext()
274760
275761 historyRepo := repomocks.NewMockHistoryRepository(t)
276762
277- 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)
278778
279779 err := uc.ClearRange(ctx, "bogus")
280780 require.Error(t, err)
281781 assert.Contains(t, err.Error(), "unknown history delete range")
782+ assert.Empty(t, sink.changes)
282783 }
283784
284785 func TestSearchHistoryUseCase_GetRecentByDomainAllowsUnderscoreDomains(t *testing.T) {
M internal/domain/entity/history_window.go +7 -5
@@ -2,10 +2,12 @@ package entity
22
33 import "time"
44
5-// HistoryWindow contains one fixed-duration slice of browsing history.
5+// HistoryWindow contains one bounded slice of browsing history plus display window metadata.
66 type HistoryWindow struct {
7- Entries []*HistoryEntry `json:"entries"`
8- Before time.Time `json:"before"`
9- After time.Time `json:"after"`
10- HasMore bool `json:"has_more"`
7+ Entries []*HistoryEntry `json:"entries"`
8+ Before time.Time `json:"before"`
9+ After time.Time `json:"after"`
10+ CursorLastVisited time.Time `json:"cursor_last_visited"`
11+ CursorID int64 `json:"cursor_id,omitempty"`
12+ HasMore bool `json:"has_more"`
1113 }
M internal/domain/repository/history.go +4 -10
@@ -24,17 +24,11 @@ type HistoryRepository interface {
2424 // GetRecentByDomain retrieves recent history entries for a domain with pagination. A zero limit means all matching entries.
2525 GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error)
2626
27- // GetRecentWindow retrieves entries visited in [after, before), sorted by recency.
28- GetRecentWindow(ctx context.Context, before, after time.Time) ([]*entity.HistoryEntry, error)
27+ // GetRecentWindow retrieves entries older than the stable (last_visited, id) cursor, sorted by recency.
28+ GetRecentWindow(ctx context.Context, before time.Time, beforeID int64, limit int) ([]*entity.HistoryEntry, error)
2929
30- // GetRecentWindowByDomain retrieves domain entries visited in [after, before), sorted by recency.
31- GetRecentWindowByDomain(ctx context.Context, domain string, before, after time.Time) ([]*entity.HistoryEntry, error)
32-
33- // HasEntriesBefore reports whether any history entry was visited before the given time.
34- HasEntriesBefore(ctx context.Context, before time.Time) (bool, error)
35-
36- // HasEntriesByDomainBefore reports whether any domain entry was visited before the given time.
37- HasEntriesByDomainBefore(ctx context.Context, domain string, before time.Time) (bool, error)
30+ // GetRecentWindowByDomain retrieves domain entries older than the stable (last_visited, id) cursor, sorted by recency.
31+ GetRecentWindowByDomain(ctx context.Context, domain string, before time.Time, beforeID int64, limit int) ([]*entity.HistoryEntry, error)
3832
3933 // GetRecentSince retrieves history entries visited within the last N days.
4034 // days must be > 0.
M internal/domain/repository/mocks/history.go +42 -168
@@ -999,8 +999,8 @@ func (_c *MockHistoryRepository_GetRecentSince_Call) RunAndReturn(run func(ctx c
999999 }
10001000
10011001 // GetRecentWindow provides a mock function for the type MockHistoryRepository
1002-func (_mock *MockHistoryRepository) GetRecentWindow(ctx context.Context, before time.Time, after time.Time) ([]*entity.HistoryEntry, error) {
1003- ret := _mock.Called(ctx, before, after)
1002+func (_mock *MockHistoryRepository) GetRecentWindow(ctx context.Context, before time.Time, beforeID int64, limit int) ([]*entity.HistoryEntry, error) {
1003+ ret := _mock.Called(ctx, before, beforeID, limit)
10041004
10051005 if len(ret) == 0 {
10061006 panic("no return value specified for GetRecentWindow")
@@ -1008,18 +1008,18 @@ func (_mock *MockHistoryRepository) GetRecentWindow(ctx context.Context, before
10081008
10091009 var r0 []*entity.HistoryEntry
10101010 var r1 error
1011- if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, time.Time) ([]*entity.HistoryEntry, error)); ok {
1012- return returnFunc(ctx, before, after)
1011+ if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, int64, int) ([]*entity.HistoryEntry, error)); ok {
1012+ return returnFunc(ctx, before, beforeID, limit)
10131013 }
1014- if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, time.Time) []*entity.HistoryEntry); ok {
1015- r0 = returnFunc(ctx, before, after)
1014+ if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, int64, int) []*entity.HistoryEntry); ok {
1015+ r0 = returnFunc(ctx, before, beforeID, limit)
10161016 } else {
10171017 if ret.Get(0) != nil {
10181018 r0 = ret.Get(0).([]*entity.HistoryEntry)
10191019 }
10201020 }
1021- if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time, time.Time) error); ok {
1022- r1 = returnFunc(ctx, before, after)
1021+ if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time, int64, int) error); ok {
1022+ r1 = returnFunc(ctx, before, beforeID, limit)
10231023 } else {
10241024 r1 = ret.Error(1)
10251025 }
@@ -1034,12 +1034,13 @@ type MockHistoryRepository_GetRecentWindow_Call struct {
10341034 // GetRecentWindow is a helper method to define mock.On call
10351035 // - ctx context.Context
10361036 // - before time.Time
1037-// - after time.Time
1038-func (_e *MockHistoryRepository_Expecter) GetRecentWindow(ctx any, before any, after any) *MockHistoryRepository_GetRecentWindow_Call {
1039- return &MockHistoryRepository_GetRecentWindow_Call{Call: _e.mock.On("GetRecentWindow", ctx, before, after)}
1037+// - beforeID int64
1038+// - limit int
1039+func (_e *MockHistoryRepository_Expecter) GetRecentWindow(ctx any, before any, beforeID any, limit any) *MockHistoryRepository_GetRecentWindow_Call {
1040+ return &MockHistoryRepository_GetRecentWindow_Call{Call: _e.mock.On("GetRecentWindow", ctx, before, beforeID, limit)}
10401041 }
10411042
1042-func (_c *MockHistoryRepository_GetRecentWindow_Call) Run(run func(ctx context.Context, before time.Time, after time.Time)) *MockHistoryRepository_GetRecentWindow_Call {
1043+func (_c *MockHistoryRepository_GetRecentWindow_Call) Run(run func(ctx context.Context, before time.Time, beforeID int64, limit int)) *MockHistoryRepository_GetRecentWindow_Call {
10431044 _c.Call.Run(func(args mock.Arguments) {
10441045 var arg0 context.Context
10451046 if args[0] != nil {
@@ -1049,14 +1050,19 @@ func (_c *MockHistoryRepository_GetRecentWindow_Call) Run(run func(ctx context.C
10491050 if args[1] != nil {
10501051 arg1 = args[1].(time.Time)
10511052 }
1052- var arg2 time.Time
1053+ var arg2 int64
10531054 if args[2] != nil {
1054- arg2 = args[2].(time.Time)
1055+ arg2 = args[2].(int64)
1056+ }
1057+ var arg3 int
1058+ if args[3] != nil {
1059+ arg3 = args[3].(int)
10551060 }
10561061 run(
10571062 arg0,
10581063 arg1,
10591064 arg2,
1065+ arg3,
10601066 )
10611067 })
10621068 return _c
@@ -1067,14 +1073,14 @@ func (_c *MockHistoryRepository_GetRecentWindow_Call) Return(historyEntrys []*en
10671073 return _c
10681074 }
10691075
1070-func (_c *MockHistoryRepository_GetRecentWindow_Call) RunAndReturn(run func(ctx context.Context, before time.Time, after time.Time) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecentWindow_Call {
1076+func (_c *MockHistoryRepository_GetRecentWindow_Call) RunAndReturn(run func(ctx context.Context, before time.Time, beforeID int64, limit int) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecentWindow_Call {
10711077 _c.Call.Return(run)
10721078 return _c
10731079 }
10741080
10751081 // GetRecentWindowByDomain provides a mock function for the type MockHistoryRepository
1076-func (_mock *MockHistoryRepository) GetRecentWindowByDomain(ctx context.Context, domain string, before time.Time, after time.Time) ([]*entity.HistoryEntry, error) {
1077- ret := _mock.Called(ctx, domain, before, after)
1082+func (_mock *MockHistoryRepository) GetRecentWindowByDomain(ctx context.Context, domain string, before time.Time, beforeID int64, limit int) ([]*entity.HistoryEntry, error) {
1083+ ret := _mock.Called(ctx, domain, before, beforeID, limit)
10781084
10791085 if len(ret) == 0 {
10801086 panic("no return value specified for GetRecentWindowByDomain")
@@ -1082,18 +1088,18 @@ func (_mock *MockHistoryRepository) GetRecentWindowByDomain(ctx context.Context,
10821088
10831089 var r0 []*entity.HistoryEntry
10841090 var r1 error
1085- if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time, time.Time) ([]*entity.HistoryEntry, error)); ok {
1086- return returnFunc(ctx, domain, before, after)
1091+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time, int64, int) ([]*entity.HistoryEntry, error)); ok {
1092+ return returnFunc(ctx, domain, before, beforeID, limit)
10871093 }
1088- if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time, time.Time) []*entity.HistoryEntry); ok {
1089- r0 = returnFunc(ctx, domain, before, after)
1094+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time, int64, int) []*entity.HistoryEntry); ok {
1095+ r0 = returnFunc(ctx, domain, before, beforeID, limit)
10901096 } else {
10911097 if ret.Get(0) != nil {
10921098 r0 = ret.Get(0).([]*entity.HistoryEntry)
10931099 }
10941100 }
1095- if returnFunc, ok := ret.Get(1).(func(context.Context, string, time.Time, time.Time) error); ok {
1096- r1 = returnFunc(ctx, domain, before, after)
1101+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, time.Time, int64, int) error); ok {
1102+ r1 = returnFunc(ctx, domain, before, beforeID, limit)
10971103 } else {
10981104 r1 = ret.Error(1)
10991105 }
@@ -1109,12 +1115,13 @@ type MockHistoryRepository_GetRecentWindowByDomain_Call struct {
11091115 // - ctx context.Context
11101116 // - domain string
11111117 // - before time.Time
1112-// - after time.Time
1113-func (_e *MockHistoryRepository_Expecter) GetRecentWindowByDomain(ctx any, domain any, before any, after any) *MockHistoryRepository_GetRecentWindowByDomain_Call {
1114- return &MockHistoryRepository_GetRecentWindowByDomain_Call{Call: _e.mock.On("GetRecentWindowByDomain", ctx, domain, before, after)}
1118+// - beforeID int64
1119+// - limit int
1120+func (_e *MockHistoryRepository_Expecter) GetRecentWindowByDomain(ctx any, domain any, before any, beforeID any, limit any) *MockHistoryRepository_GetRecentWindowByDomain_Call {
1121+ return &MockHistoryRepository_GetRecentWindowByDomain_Call{Call: _e.mock.On("GetRecentWindowByDomain", ctx, domain, before, beforeID, limit)}
11151122 }
11161123
1117-func (_c *MockHistoryRepository_GetRecentWindowByDomain_Call) Run(run func(ctx context.Context, domain string, before time.Time, after time.Time)) *MockHistoryRepository_GetRecentWindowByDomain_Call {
1124+func (_c *MockHistoryRepository_GetRecentWindowByDomain_Call) Run(run func(ctx context.Context, domain string, before time.Time, beforeID int64, limit int)) *MockHistoryRepository_GetRecentWindowByDomain_Call {
11181125 _c.Call.Run(func(args mock.Arguments) {
11191126 var arg0 context.Context
11201127 if args[0] != nil {
@@ -1128,15 +1135,20 @@ func (_c *MockHistoryRepository_GetRecentWindowByDomain_Call) Run(run func(ctx c
11281135 if args[2] != nil {
11291136 arg2 = args[2].(time.Time)
11301137 }
1131- var arg3 time.Time
1138+ var arg3 int64
11321139 if args[3] != nil {
1133- arg3 = args[3].(time.Time)
1140+ arg3 = args[3].(int64)
1141+ }
1142+ var arg4 int
1143+ if args[4] != nil {
1144+ arg4 = args[4].(int)
11341145 }
11351146 run(
11361147 arg0,
11371148 arg1,
11381149 arg2,
11391150 arg3,
1151+ arg4,
11401152 )
11411153 })
11421154 return _c
@@ -1147,7 +1159,7 @@ func (_c *MockHistoryRepository_GetRecentWindowByDomain_Call) Return(historyEntr
11471159 return _c
11481160 }
11491161
1150-func (_c *MockHistoryRepository_GetRecentWindowByDomain_Call) RunAndReturn(run func(ctx context.Context, domain string, before time.Time, after time.Time) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecentWindowByDomain_Call {
1162+func (_c *MockHistoryRepository_GetRecentWindowByDomain_Call) RunAndReturn(run func(ctx context.Context, domain string, before time.Time, beforeID int64, limit int) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecentWindowByDomain_Call {
11511163 _c.Call.Return(run)
11521164 return _c
11531165 }
@@ -1214,144 +1226,6 @@ func (_c *MockHistoryRepository_GetStats_Call) RunAndReturn(run func(ctx context
12141226 return _c
12151227 }
12161228
1217-// HasEntriesBefore provides a mock function for the type MockHistoryRepository
1218-func (_mock *MockHistoryRepository) HasEntriesBefore(ctx context.Context, before time.Time) (bool, error) {
1219- ret := _mock.Called(ctx, before)
1220-
1221- if len(ret) == 0 {
1222- panic("no return value specified for HasEntriesBefore")
1223- }
1224-
1225- var r0 bool
1226- var r1 error
1227- if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) (bool, error)); ok {
1228- return returnFunc(ctx, before)
1229- }
1230- if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) bool); ok {
1231- r0 = returnFunc(ctx, before)
1232- } else {
1233- r0 = ret.Get(0).(bool)
1234- }
1235- if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time) error); ok {
1236- r1 = returnFunc(ctx, before)
1237- } else {
1238- r1 = ret.Error(1)
1239- }
1240- return r0, r1
1241-}
1242-
1243-// MockHistoryRepository_HasEntriesBefore_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasEntriesBefore'
1244-type MockHistoryRepository_HasEntriesBefore_Call struct {
1245- *mock.Call
1246-}
1247-
1248-// HasEntriesBefore is a helper method to define mock.On call
1249-// - ctx context.Context
1250-// - before time.Time
1251-func (_e *MockHistoryRepository_Expecter) HasEntriesBefore(ctx any, before any) *MockHistoryRepository_HasEntriesBefore_Call {
1252- return &MockHistoryRepository_HasEntriesBefore_Call{Call: _e.mock.On("HasEntriesBefore", ctx, before)}
1253-}
1254-
1255-func (_c *MockHistoryRepository_HasEntriesBefore_Call) Run(run func(ctx context.Context, before time.Time)) *MockHistoryRepository_HasEntriesBefore_Call {
1256- _c.Call.Run(func(args mock.Arguments) {
1257- var arg0 context.Context
1258- if args[0] != nil {
1259- arg0 = args[0].(context.Context)
1260- }
1261- var arg1 time.Time
1262- if args[1] != nil {
1263- arg1 = args[1].(time.Time)
1264- }
1265- run(
1266- arg0,
1267- arg1,
1268- )
1269- })
1270- return _c
1271-}
1272-
1273-func (_c *MockHistoryRepository_HasEntriesBefore_Call) Return(b bool, err error) *MockHistoryRepository_HasEntriesBefore_Call {
1274- _c.Call.Return(b, err)
1275- return _c
1276-}
1277-
1278-func (_c *MockHistoryRepository_HasEntriesBefore_Call) RunAndReturn(run func(ctx context.Context, before time.Time) (bool, error)) *MockHistoryRepository_HasEntriesBefore_Call {
1279- _c.Call.Return(run)
1280- return _c
1281-}
1282-
1283-// HasEntriesByDomainBefore provides a mock function for the type MockHistoryRepository
1284-func (_mock *MockHistoryRepository) HasEntriesByDomainBefore(ctx context.Context, domain string, before time.Time) (bool, error) {
1285- ret := _mock.Called(ctx, domain, before)
1286-
1287- if len(ret) == 0 {
1288- panic("no return value specified for HasEntriesByDomainBefore")
1289- }
1290-
1291- var r0 bool
1292- var r1 error
1293- if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time) (bool, error)); ok {
1294- return returnFunc(ctx, domain, before)
1295- }
1296- if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time) bool); ok {
1297- r0 = returnFunc(ctx, domain, before)
1298- } else {
1299- r0 = ret.Get(0).(bool)
1300- }
1301- if returnFunc, ok := ret.Get(1).(func(context.Context, string, time.Time) error); ok {
1302- r1 = returnFunc(ctx, domain, before)
1303- } else {
1304- r1 = ret.Error(1)
1305- }
1306- return r0, r1
1307-}
1308-
1309-// MockHistoryRepository_HasEntriesByDomainBefore_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasEntriesByDomainBefore'
1310-type MockHistoryRepository_HasEntriesByDomainBefore_Call struct {
1311- *mock.Call
1312-}
1313-
1314-// HasEntriesByDomainBefore is a helper method to define mock.On call
1315-// - ctx context.Context
1316-// - domain string
1317-// - before time.Time
1318-func (_e *MockHistoryRepository_Expecter) HasEntriesByDomainBefore(ctx any, domain any, before any) *MockHistoryRepository_HasEntriesByDomainBefore_Call {
1319- return &MockHistoryRepository_HasEntriesByDomainBefore_Call{Call: _e.mock.On("HasEntriesByDomainBefore", ctx, domain, before)}
1320-}
1321-
1322-func (_c *MockHistoryRepository_HasEntriesByDomainBefore_Call) Run(run func(ctx context.Context, domain string, before time.Time)) *MockHistoryRepository_HasEntriesByDomainBefore_Call {
1323- _c.Call.Run(func(args mock.Arguments) {
1324- var arg0 context.Context
1325- if args[0] != nil {
1326- arg0 = args[0].(context.Context)
1327- }
1328- var arg1 string
1329- if args[1] != nil {
1330- arg1 = args[1].(string)
1331- }
1332- var arg2 time.Time
1333- if args[2] != nil {
1334- arg2 = args[2].(time.Time)
1335- }
1336- run(
1337- arg0,
1338- arg1,
1339- arg2,
1340- )
1341- })
1342- return _c
1343-}
1344-
1345-func (_c *MockHistoryRepository_HasEntriesByDomainBefore_Call) Return(b bool, err error) *MockHistoryRepository_HasEntriesByDomainBefore_Call {
1346- _c.Call.Return(b, err)
1347- return _c
1348-}
1349-
1350-func (_c *MockHistoryRepository_HasEntriesByDomainBefore_Call) RunAndReturn(run func(ctx context.Context, domain string, before time.Time) (bool, error)) *MockHistoryRepository_HasEntriesByDomainBefore_Call {
1351- _c.Call.Return(run)
1352- return _c
1353-}
1354-
13551229 // IncrementVisitCount provides a mock function for the type MockHistoryRepository
13561230 func (_mock *MockHistoryRepository) IncrementVisitCount(ctx context.Context, url string) error {
13571231 ret := _mock.Called(ctx, url)
M internal/infrastructure/cef/webview_handlers.go +43 -2
@@ -542,8 +542,49 @@ func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatu
542542 // CEF's OnLoadingProgressChange also provides progress=1.0 at completion.
543543 }
544544
545-// OnLoadError is a no-op.
546-func (h *handlerSet) OnLoadError(_ purecef.Browser, _ purecef.Frame, _ purecef.Errorcode, _, _ string) {
545+const cefErrAborted purecef.Errorcode = -3
546+
547+// OnLoadError handles failed main-frame navigations that never reach
548+// OnLoadStart. CEF still sends OnLoadingStateChange(false) after load errors,
549+// so this callback only synthesizes the missing committed event needed to reveal
550+// the pane; the normal browser-level loading-state callback remains responsible
551+// for the terminal LoadFinished state.
552+func (h *handlerSet) OnLoadError(_ purecef.Browser, frame purecef.Frame, errorCode purecef.Errorcode, errorText, failedURL string) {
553+ if h == nil || h.wv == nil || frame == nil || !frame.IsMain() {
554+ return
555+ }
556+ if errorCode == cefErrAborted {
557+ return
558+ }
559+ if failedURL == "" {
560+ failedURL = frame.GetURL()
561+ }
562+
563+ h.wv.mu.RLock()
564+ currentURI := h.wv.uri
565+ cb := h.wv.callbacks
566+ h.wv.mu.RUnlock()
567+
568+ if h.wv.ctx != nil {
569+ logging.FromContext(h.wv.ctx).Debug().
570+ Int32("error_code", errorCode).
571+ Str("error_text", errorText).
572+ Str("failed_url", logging.TruncateURL(failedURL, logging.PermissionLogURLMaxLen)).
573+ Msg("cef: OnLoadError")
574+ }
575+
576+ if failedURL != "" {
577+ h.wv.updateURI(failedURL)
578+ }
579+
580+ if cb == nil || cb.OnLoadChanged == nil {
581+ return
582+ }
583+ if failedURL != "" && !pendingURIEquivalent(currentURI, failedURL) {
584+ h.wv.runOnGTK(func() {
585+ cb.OnLoadChanged(port.LoadCommitted)
586+ })
587+ }
547588 }
548589
549590 func mapCEFWindowDisposition(disposition purecef.WindowOpenDisposition) dto.WindowDisposition {
M internal/infrastructure/cef/webview_handlers_test.go +85 -0
@@ -423,6 +423,91 @@ func TestOnLoadEndDoesNotDispatchBrowserLevelCompletion(t *testing.T) {
423423 assert.Empty(t, gotProgress)
424424 }
425425
426+func TestOnLoadErrorMainFrameCommitsFailedNavigation(t *testing.T) {
427+ wv := &WebView{ctx: context.Background()}
428+ wv.updateLoadState(true, true, false)
429+ var gotEvents []port.LoadEvent
430+ wv.SetCallbacks(&port.WebViewCallbacks{
431+ OnLoadChanged: func(event port.LoadEvent) {
432+ gotEvents = append(gotEvents, event)
433+ },
434+ })
435+
436+ h := &handlerSet{wv: wv}
437+ h.OnLoadError(nil, stubFrame{main: true, url: "http://localhost:9"}, -102, "CONNECTION_REFUSED", "http://localhost:9")
438+
439+ require.Equal(t, []port.LoadEvent{port.LoadCommitted}, gotEvents)
440+ require.Equal(t, "http://localhost:9", wv.URI())
441+ require.True(t, wv.IsLoading())
442+ require.True(t, wv.CanGoBack())
443+}
444+
445+func TestOnLoadErrorFollowedByLoadingStateFinishTerminatesNavigation(t *testing.T) {
446+ wv := &WebView{ctx: context.Background()}
447+ wv.updateLoadState(true, false, false)
448+ var gotEvents []port.LoadEvent
449+ wv.SetCallbacks(&port.WebViewCallbacks{
450+ OnLoadChanged: func(event port.LoadEvent) {
451+ gotEvents = append(gotEvents, event)
452+ },
453+ })
454+
455+ h := &handlerSet{wv: wv}
456+ h.OnLoadError(nil, stubFrame{main: true, url: "http://localhost:9"}, -102, "CONNECTION_REFUSED", "http://localhost:9")
457+ h.OnLoadingStateChange(nil, 0, 0, 0)
458+
459+ require.Equal(t, []port.LoadEvent{port.LoadCommitted, port.LoadFinished}, gotEvents)
460+ require.False(t, wv.IsLoading())
461+}
462+
463+func TestOnLoadErrorAfterLoadStartDoesNotDispatchDuplicateEvents(t *testing.T) {
464+ wv := &WebView{ctx: context.Background()}
465+ var gotEvents []port.LoadEvent
466+ wv.SetCallbacks(&port.WebViewCallbacks{
467+ OnLoadChanged: func(event port.LoadEvent) {
468+ gotEvents = append(gotEvents, event)
469+ },
470+ })
471+
472+ h := &handlerSet{wv: wv}
473+ h.OnLoadStart(nil, stubFrame{main: true, url: "http://localhost:9"}, 0)
474+ gotEvents = nil
475+ h.OnLoadError(nil, stubFrame{main: true, url: "http://localhost:9"}, -102, "CONNECTION_REFUSED", "http://localhost:9")
476+
477+ require.Empty(t, gotEvents)
478+}
479+
480+func TestOnLoadErrorAbortedNavigationDoesNotCommit(t *testing.T) {
481+ wv := &WebView{ctx: context.Background()}
482+ var gotEvents []port.LoadEvent
483+ wv.SetCallbacks(&port.WebViewCallbacks{
484+ OnLoadChanged: func(event port.LoadEvent) {
485+ gotEvents = append(gotEvents, event)
486+ },
487+ })
488+
489+ h := &handlerSet{wv: wv}
490+ h.OnLoadError(nil, stubFrame{main: true, url: "https://example.com/file.zip"}, cefErrAborted, "ERR_ABORTED", "https://example.com/file.zip")
491+
492+ require.Empty(t, gotEvents)
493+ require.Empty(t, wv.URI())
494+}
495+
496+func TestOnLoadErrorSubFrameDoesNotTerminateNavigation(t *testing.T) {
497+ wv := &WebView{ctx: context.Background()}
498+ var gotEvents []port.LoadEvent
499+ wv.SetCallbacks(&port.WebViewCallbacks{
500+ OnLoadChanged: func(event port.LoadEvent) {
501+ gotEvents = append(gotEvents, event)
502+ },
503+ })
504+
505+ h := &handlerSet{wv: wv}
506+ h.OnLoadError(nil, stubFrame{main: false, url: "http://localhost:9/frame"}, -102, "CONNECTION_REFUSED", "http://localhost:9/frame")
507+
508+ assert.Empty(t, gotEvents)
509+}
510+
426511 func TestOnTextSelectionChanged_ForwardsSelectionToClipboardOrchestrator(t *testing.T) {
427512 recorder := &clipboardOrchestratorRecorder{}
428513 orchestrator := portmocks.NewMockClipboardTextOrchestrator(t)
M internal/infrastructure/handlers/homepage/history.go +11 -2
@@ -40,6 +40,7 @@ type timelineByDomainRequest struct {
4040 type timelineWindowRequest struct {
4141 RequestID string `json:"requestId"`
4242 Before string `json:"before"`
43+ BeforeID int64 `json:"beforeId"`
4344 Domain string `json:"domain"`
4445 }
4546
@@ -124,7 +125,15 @@ func (h *HistoryHandlers) HandleTimelineWindow() port.WebUIMessageHandler {
124125 domain := strings.TrimSpace(req.Domain)
125126
126127 var before time.Time
127- if strings.TrimSpace(req.Before) != "" {
128+ hasBefore := strings.TrimSpace(req.Before) != ""
129+ if req.BeforeID < 0 {
130+ return NewErrorResponse(req.RequestID, fmt.Errorf("history window cursor id must be non-negative")), nil
131+ }
132+ hasBeforeID := req.BeforeID > 0
133+ if hasBefore != hasBeforeID {
134+ return NewErrorResponse(req.RequestID, fmt.Errorf("history window cursor requires before and beforeId")), nil
135+ }
136+ if hasBefore {
128137 parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(req.Before))
129138 if err != nil {
130139 return NewErrorResponse(req.RequestID, fmt.Errorf("invalid history window cursor")), nil
@@ -138,7 +147,7 @@ func (h *HistoryHandlers) HandleTimelineWindow() port.WebUIMessageHandler {
138147 Time("before", before).
139148 Msg("handling history_timeline_window")
140149
141- window, err := h.historyUC.GetRecentWindow(ctx, before, domain)
150+ window, err := h.historyUC.GetRecentWindow(ctx, before, req.BeforeID, domain)
142151 if err != nil {
143152 return NewErrorResponse(req.RequestID, err), nil
144153 }
A internal/infrastructure/handlers/homepage/history_test.go +61 -0
@@ -0,0 +1,61 @@
1+package homepage
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
11+ "github.com/bnema/dumber/internal/domain/entity"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestHandleTimelineWindowRejectsIncompleteCursor(t *testing.T) {
17+ t.Parallel()
18+
19+ tests := map[string]json.RawMessage{
20+ "before without beforeId": json.RawMessage(`{"requestId":"req-1","before":"2026-04-25T09:00:00Z"}`),
21+ "beforeId without before": json.RawMessage(`{"requestId":"req-1","beforeId":42}`),
22+ "negative beforeId without before": json.RawMessage(`{"requestId":"req-1","beforeId":-1}`),
23+ }
24+ for name, payload := range tests {
25+ t.Run(name, func(t *testing.T) {
26+ t.Parallel()
27+
28+ history := portmocks.NewMockHomepageHistory(t)
29+ handler := NewHistoryHandlers(history).HandleTimelineWindow()
30+
31+ got, err := handler.Handle(context.Background(), port.WebViewID(0), payload)
32+ require.NoError(t, err)
33+
34+ resp, ok := got.(Response)
35+ require.True(t, ok)
36+ require.False(t, resp.Success)
37+ require.Contains(t, resp.Error, "cursor")
38+ })
39+ }
40+}
41+
42+func TestHandleTimelineWindowPassesCompleteCursor(t *testing.T) {
43+ t.Parallel()
44+
45+ before := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC)
46+ history := portmocks.NewMockHomepageHistory(t)
47+ history.EXPECT().
48+ GetRecentWindow(mock.Anything, before, int64(42), "example.com").
49+ Return(&entity.HistoryWindow{Before: before, CursorID: 42}, nil).
50+ Once()
51+
52+ handler := NewHistoryHandlers(history).HandleTimelineWindow()
53+ payload := json.RawMessage(`{"requestId":"req-2","before":"2026-04-25T09:00:00Z","beforeId":42,"domain":"example.com"}`)
54+
55+ got, err := handler.Handle(context.Background(), port.WebViewID(0), payload)
56+ require.NoError(t, err)
57+
58+ resp, ok := got.(Response)
59+ require.True(t, ok)
60+ require.True(t, resp.Success)
61+}
M internal/infrastructure/persistence/sqlite/history_repo.go +21 -31
@@ -524,10 +524,14 @@ func (r *historyRepo) GetRecentByDomain(ctx context.Context, domain string, limi
524524 return historyEntriesFromRows(rows), nil
525525 }
526526
527-func (r *historyRepo) GetRecentWindow(ctx context.Context, before, after time.Time) ([]*entity.HistoryEntry, error) {
527+func (r *historyRepo) GetRecentWindow(ctx context.Context, before time.Time, beforeID int64, limit int) ([]*entity.HistoryEntry, error) {
528+ if limit <= 0 {
529+ return []*entity.HistoryEntry{}, nil
530+ }
528531 rows, err := r.queries.GetRecentHistoryWindow(ctx, sqlc.GetRecentHistoryWindowParams{
529- Before: sql.NullTime{Time: before, Valid: true},
530- After: sql.NullTime{Time: after, Valid: true},
532+ Before: sql.NullTime{Time: before, Valid: true},
533+ BeforeID: beforeID,
534+ Limit: int64(limit),
531535 })
532536 if err != nil {
533537 return nil, err
@@ -535,16 +539,26 @@ func (r *historyRepo) GetRecentWindow(ctx context.Context, before, after time.Ti
535539 return historyEntriesFromRows(rows), nil
536540 }
537541
538-func (r *historyRepo) GetRecentWindowByDomain(ctx context.Context, domain string, before, after time.Time) ([]*entity.HistoryEntry, error) {
542+func (r *historyRepo) GetRecentWindowByDomain(
543+ ctx context.Context,
544+ domain string,
545+ before time.Time,
546+ beforeID int64,
547+ limit int,
548+) ([]*entity.HistoryEntry, error) {
539549 rawDomain := domain
540550 domain = domainurl.CanonicalDomain(domain)
541551 if domain == "" {
542552 return nil, fmt.Errorf("history domain is required: %q", rawDomain)
543553 }
554+ if limit <= 0 {
555+ return []*entity.HistoryEntry{}, nil
556+ }
544557 rows, err := r.queries.GetRecentHistoryWindowByDomain(ctx, sqlc.GetRecentHistoryWindowByDomainParams{
545- Domain: sql.NullString{String: domain, Valid: true},
546- Before: sql.NullTime{Time: before, Valid: true},
547- After: sql.NullTime{Time: after, Valid: true},
558+ Domain: sql.NullString{String: domain, Valid: true},
559+ Before: sql.NullTime{Time: before, Valid: true},
560+ BeforeID: beforeID,
561+ Limit: int64(limit),
548562 })
549563 if err != nil {
550564 return nil, err
@@ -552,30 +566,6 @@ func (r *historyRepo) GetRecentWindowByDomain(ctx context.Context, domain string
552566 return historyEntriesFromRows(rows), nil
553567 }
554568
555-func (r *historyRepo) HasEntriesBefore(ctx context.Context, before time.Time) (bool, error) {
556- value, err := r.queries.HasHistoryBefore(ctx, sql.NullTime{Time: before, Valid: true})
557- if err != nil {
558- return false, err
559- }
560- return value != 0, nil
561-}
562-
563-func (r *historyRepo) HasEntriesByDomainBefore(ctx context.Context, domain string, before time.Time) (bool, error) {
564- rawDomain := domain
565- domain = domainurl.CanonicalDomain(domain)
566- if domain == "" {
567- return false, fmt.Errorf("history domain is required: %q", rawDomain)
568- }
569- value, err := r.queries.HasHistoryByDomainBefore(ctx, sqlc.HasHistoryByDomainBeforeParams{
570- Domain: sql.NullString{String: domain, Valid: true},
571- Before: sql.NullTime{Time: before, Valid: true},
572- })
573- if err != nil {
574- return false, err
575- }
576- return value != 0, nil
577-}
578-
579569 func (r *historyRepo) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
580570 if days <= 0 {
581571 return nil, fmt.Errorf("days must be positive, got %d", days)
M internal/infrastructure/persistence/sqlite/history_repo_test.go +47 -0
@@ -2,6 +2,7 @@ package sqlite_test
22
33 import (
44 "context"
5+ "fmt"
56 "path/filepath"
67 "testing"
78 "time"
@@ -890,3 +891,49 @@ func TestHistoryRepository_DomainColumnPowersFilteringStatsAndDelete(t *testing.
890891 require.Len(t, remaining, 1)
891892 assert.Equal(t, "https://other.test", remaining[0].URL)
892893 }
894+func TestHistoryRepository_GetRecentWindow_StableCursorWithTimestampTies(t *testing.T) {
895+ ctx := historyTestCtx()
896+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
897+
898+ db, err := sqlite.NewConnection(ctx, dbPath)
899+ require.NoError(t, err)
900+ t.Cleanup(func() { _ = db.Close() })
901+
902+ repo := sqlite.NewHistoryRepository(db)
903+ tied := time.Date(2026, 6, 24, 10, 0, 0, 0, time.UTC)
904+ older := tied.Add(-time.Hour)
905+ for i := 1; i <= 5; i++ {
906+ _, err = db.ExecContext(ctx, `INSERT INTO history (url, title, domain, last_visited, created_at) VALUES (?, ?, ?, ?, ?)`,
907+ fmt.Sprintf("https://example.com/%d", i), fmt.Sprintf("Entry %d", i), "example.com", tied, tied)
908+ require.NoError(t, err)
909+ }
910+ _, err = db.ExecContext(ctx, `INSERT INTO history (url, title, domain, last_visited, created_at) VALUES (?, ?, ?, ?, ?)`,
911+ "https://example.com/older", "Older", "example.com", older, older)
912+ require.NoError(t, err)
913+
914+ page1, err := repo.GetRecentWindow(ctx, tied.Add(time.Second), 0, 3)
915+ require.NoError(t, err)
916+ require.Len(t, page1, 3)
917+ assert.Equal(t, []int64{5, 4, 3}, historyIDs(page1))
918+
919+ page2, err := repo.GetRecentWindow(ctx, page1[len(page1)-1].LastVisited, page1[len(page1)-1].ID, 3)
920+ require.NoError(t, err)
921+ require.Len(t, page2, 3)
922+ assert.Equal(t, []int64{2, 1, 6}, historyIDs(page2))
923+
924+ seen := map[int64]bool{}
925+ for _, entry := range append(page1, page2...) {
926+ if seen[entry.ID] {
927+ t.Fatalf("duplicate history id %d", entry.ID)
928+ }
929+ seen[entry.ID] = true
930+ }
931+}
932+
933+func historyIDs(entries []*entity.HistoryEntry) []int64 {
934+ ids := make([]int64, len(entries))
935+ for i, entry := range entries {
936+ ids[i] = entry.ID
937+ }
938+ return ids
939+}
M internal/infrastructure/persistence/sqlite/lazy_repos.go +10 -18
@@ -156,37 +156,29 @@ func (r *LazyHistoryRepository) GetRecentByDomain(ctx context.Context, domain st
156156 return r.repo.GetRecentByDomain(ctx, domain, limit, offset)
157157 }
158158
159-func (r *LazyHistoryRepository) GetRecentWindow(ctx context.Context, before, after time.Time) ([]*entity.HistoryEntry, error) {
159+func (r *LazyHistoryRepository) GetRecentWindow(
160+ ctx context.Context,
161+ before time.Time,
162+ beforeID int64,
163+ limit int,
164+) ([]*entity.HistoryEntry, error) {
160165 if err := r.init(ctx); err != nil {
161166 return nil, err
162167 }
163- return r.repo.GetRecentWindow(ctx, before, after)
168+ return r.repo.GetRecentWindow(ctx, before, beforeID, limit)
164169 }
165170
166171 func (r *LazyHistoryRepository) GetRecentWindowByDomain(
167172 ctx context.Context,
168173 domain string,
169174 before time.Time,
170- after time.Time,
175+ beforeID int64,
176+ limit int,
171177 ) ([]*entity.HistoryEntry, error) {
172178 if err := r.init(ctx); err != nil {
173179 return nil, err
174180 }
175- return r.repo.GetRecentWindowByDomain(ctx, domain, before, after)
176-}
177-
178-func (r *LazyHistoryRepository) HasEntriesBefore(ctx context.Context, before time.Time) (bool, error) {
179- if err := r.init(ctx); err != nil {
180- return false, err
181- }
182- return r.repo.HasEntriesBefore(ctx, before)
183-}
184-
185-func (r *LazyHistoryRepository) HasEntriesByDomainBefore(ctx context.Context, domain string, before time.Time) (bool, error) {
186- if err := r.init(ctx); err != nil {
187- return false, err
188- }
189- return r.repo.HasEntriesByDomainBefore(ctx, domain, before)
181+ return r.repo.GetRecentWindowByDomain(ctx, domain, before, beforeID, limit)
190182 }
191183
192184 func (r *LazyHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
M internal/infrastructure/persistence/sqlite/queries/history.sql +12 -14
@@ -1,32 +1,30 @@
11 -- name: GetRecentHistory :many
2-SELECT * FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ?;
2+SELECT * FROM history
3+ORDER BY last_visited DESC, id DESC
4+LIMIT ? OFFSET ?;
35
46 -- name: GetRecentHistoryByDomain :many
57 SELECT * FROM history
68 WHERE domain = @domain
7-ORDER BY last_visited DESC
9+ORDER BY last_visited DESC, id DESC
810 LIMIT @limit OFFSET @offset;
911
1012 -- name: GetAllRecentHistoryByDomain :many
1113 SELECT * FROM history
1214 WHERE domain = @domain
13-ORDER BY last_visited DESC;
15+ORDER BY last_visited DESC, id DESC;
1416
1517 -- name: GetRecentHistoryWindow :many
1618 SELECT * FROM history
17-WHERE last_visited < @before AND last_visited >= @after
18-ORDER BY last_visited DESC;
19+WHERE last_visited < @before OR (last_visited = @before AND id < @before_id)
20+ORDER BY last_visited DESC, id DESC
21+LIMIT @limit;
1922
2023 -- name: GetRecentHistoryWindowByDomain :many
2124 SELECT * FROM history
22-WHERE domain = @domain AND last_visited < @before AND last_visited >= @after
23-ORDER BY last_visited DESC;
24-
25--- name: HasHistoryBefore :one
26-SELECT EXISTS(SELECT 1 FROM history WHERE last_visited < @before LIMIT 1);
27-
28--- name: HasHistoryByDomainBefore :one
29-SELECT EXISTS(SELECT 1 FROM history WHERE domain = @domain AND last_visited < @before LIMIT 1);
25+WHERE domain = @domain AND (last_visited < @before OR (last_visited = @before AND id < @before_id))
26+ORDER BY last_visited DESC, id DESC
27+LIMIT @limit;
3028
3129 -- name: GetHistoryByURL :one
3230 SELECT * FROM history WHERE url = ? LIMIT 1;
@@ -153,7 +151,7 @@ ORDER BY visit_count DESC, last_visited DESC;
153151
154152 -- name: GetAllRecentHistory :many
155153 SELECT * FROM history
156-ORDER BY last_visited DESC;
154+ORDER BY last_visited DESC, id DESC;
157155
158156 -- name: GetAllMostVisited :many
159157 SELECT * FROM history
M internal/infrastructure/persistence/sqlite/sqlc/history.sql.go +26 -42
@@ -109,7 +109,7 @@ func (q *Queries) GetAllMostVisited(ctx context.Context) ([]History, error) {
109109
110110 const GetAllRecentHistory = `-- name: GetAllRecentHistory :many
111111 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
112-ORDER BY last_visited DESC
112+ORDER BY last_visited DESC, id DESC
113113 `
114114
115115 func (q *Queries) GetAllRecentHistory(ctx context.Context) ([]History, error) {
@@ -147,7 +147,7 @@ func (q *Queries) GetAllRecentHistory(ctx context.Context) ([]History, error) {
147147 const GetAllRecentHistoryByDomain = `-- name: GetAllRecentHistoryByDomain :many
148148 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
149149 WHERE domain = ?1
150-ORDER BY last_visited DESC
150+ORDER BY last_visited DESC, id DESC
151151 `
152152
153153 func (q *Queries) GetAllRecentHistoryByDomain(ctx context.Context, domain sql.NullString) ([]History, error) {
@@ -387,7 +387,9 @@ func (q *Queries) GetMostVisited(ctx context.Context, datetime interface{}) ([]H
387387 }
388388
389389 const GetRecentHistory = `-- name: GetRecentHistory :many
390-SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ?
390+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
391+ORDER BY last_visited DESC, id DESC
392+LIMIT ? OFFSET ?
391393 `
392394
393395 type GetRecentHistoryParams struct {
@@ -430,7 +432,7 @@ func (q *Queries) GetRecentHistory(ctx context.Context, arg GetRecentHistoryPara
430432 const GetRecentHistoryByDomain = `-- name: GetRecentHistoryByDomain :many
431433 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
432434 WHERE domain = ?1
433-ORDER BY last_visited DESC
435+ORDER BY last_visited DESC, id DESC
434436 LIMIT ?3 OFFSET ?2
435437 `
436438
@@ -512,17 +514,19 @@ func (q *Queries) GetRecentHistorySince(ctx context.Context, datetime interface{
512514
513515 const GetRecentHistoryWindow = `-- name: GetRecentHistoryWindow :many
514516 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
515-WHERE last_visited < ?1 AND last_visited >= ?2
516-ORDER BY last_visited DESC
517+WHERE last_visited < ?1 OR (last_visited = ?1 AND id < ?2)
518+ORDER BY last_visited DESC, id DESC
519+LIMIT ?3
517520 `
518521
519522 type GetRecentHistoryWindowParams struct {
520- Before sql.NullTime `json:"before"`
521- After sql.NullTime `json:"after"`
523+ Before sql.NullTime `json:"before"`
524+ BeforeID int64 `json:"before_id"`
525+ Limit int64 `json:"limit"`
522526 }
523527
524528 func (q *Queries) GetRecentHistoryWindow(ctx context.Context, arg GetRecentHistoryWindowParams) ([]History, error) {
525- rows, err := q.db.QueryContext(ctx, GetRecentHistoryWindow, arg.Before, arg.After)
529+ rows, err := q.db.QueryContext(ctx, GetRecentHistoryWindow, arg.Before, arg.BeforeID, arg.Limit)
526530 if err != nil {
527531 return nil, err
528532 }
@@ -555,18 +559,25 @@ func (q *Queries) GetRecentHistoryWindow(ctx context.Context, arg GetRecentHisto
555559
556560 const GetRecentHistoryWindowByDomain = `-- name: GetRecentHistoryWindowByDomain :many
557561 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
558-WHERE domain = ?1 AND last_visited < ?2 AND last_visited >= ?3
559-ORDER BY last_visited DESC
562+WHERE domain = ?1 AND (last_visited < ?2 OR (last_visited = ?2 AND id < ?3))
563+ORDER BY last_visited DESC, id DESC
564+LIMIT ?4
560565 `
561566
562567 type GetRecentHistoryWindowByDomainParams struct {
563- Domain sql.NullString `json:"domain"`
564- Before sql.NullTime `json:"before"`
565- After sql.NullTime `json:"after"`
568+ Domain sql.NullString `json:"domain"`
569+ Before sql.NullTime `json:"before"`
570+ BeforeID int64 `json:"before_id"`
571+ Limit int64 `json:"limit"`
566572 }
567573
568574 func (q *Queries) GetRecentHistoryWindowByDomain(ctx context.Context, arg GetRecentHistoryWindowByDomainParams) ([]History, error) {
569- rows, err := q.db.QueryContext(ctx, GetRecentHistoryWindowByDomain, arg.Domain, arg.Before, arg.After)
575+ rows, err := q.db.QueryContext(ctx, GetRecentHistoryWindowByDomain,
576+ arg.Domain,
577+ arg.Before,
578+ arg.BeforeID,
579+ arg.Limit,
580+ )
570581 if err != nil {
571582 return nil, err
572583 }
@@ -597,33 +608,6 @@ func (q *Queries) GetRecentHistoryWindowByDomain(ctx context.Context, arg GetRec
597608 return items, nil
598609 }
599610
600-const HasHistoryBefore = `-- name: HasHistoryBefore :one
601-SELECT EXISTS(SELECT 1 FROM history WHERE last_visited < ?1 LIMIT 1)
602-`
603-
604-func (q *Queries) HasHistoryBefore(ctx context.Context, before sql.NullTime) (int64, error) {
605- row := q.db.QueryRowContext(ctx, HasHistoryBefore, before)
606- var column_1 int64
607- err := row.Scan(&column_1)
608- return column_1, err
609-}
610-
611-const HasHistoryByDomainBefore = `-- name: HasHistoryByDomainBefore :one
612-SELECT EXISTS(SELECT 1 FROM history WHERE domain = ?1 AND last_visited < ?2 LIMIT 1)
613-`
614-
615-type HasHistoryByDomainBeforeParams struct {
616- Domain sql.NullString `json:"domain"`
617- Before sql.NullTime `json:"before"`
618-}
619-
620-func (q *Queries) HasHistoryByDomainBefore(ctx context.Context, arg HasHistoryByDomainBeforeParams) (int64, error) {
621- row := q.db.QueryRowContext(ctx, HasHistoryByDomainBefore, arg.Domain, arg.Before)
622- var column_1 int64
623- err := row.Scan(&column_1)
624- return column_1, err
625-}
626-
627611 const IncrementVisitCount = `-- name: IncrementVisitCount :exec
628612 UPDATE history
629613 SET visit_count = visit_count + 1, last_visited = CURRENT_TIMESTAMP
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +0 -2
@@ -72,8 +72,6 @@ type Querier interface {
7272 GetTagsForFavorite(ctx context.Context, favoriteID int64) ([]FavoriteTag, error)
7373 GetTotalSessionStatesSize(ctx context.Context) (interface{}, error)
7474 GetZoomLevel(ctx context.Context, domain string) (ZoomLevel, error)
75- HasHistoryBefore(ctx context.Context, before sql.NullTime) (int64, error)
76- HasHistoryByDomainBefore(ctx context.Context, arg HasHistoryByDomainBeforeParams) (int64, error)
7775 IncrementVisitCount(ctx context.Context, url string) error
7876 IncrementVisitCountByDelta(ctx context.Context, arg IncrementVisitCountByDeltaParams) error
7977 InsertSession(ctx context.Context, arg InsertSessionParams) error
M internal/infrastructure/systemviewsbridge/client.go +9 -2
@@ -108,7 +108,13 @@ func (c *Client) TimelineByDomain(ctx context.Context, domain string, limit, off
108108 }{RequestID: nextRequestID(), Domain: domain, Limit: limit, Offset: offset})
109109 }
110110
111-func (c *Client) TimelineWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) {
111+func (c *Client) TimelineWindow(ctx context.Context, before time.Time, beforeID int64, domain string) (*entity.HistoryWindow, error) {
112+ if beforeID < 0 {
113+ return nil, fmt.Errorf("history window cursor beforeID must be non-negative")
114+ }
115+ if before.IsZero() != (beforeID == 0) {
116+ return nil, fmt.Errorf("history window cursor requires before and beforeID")
117+ }
112118 beforeCursor := ""
113119 if !before.IsZero() {
114120 beforeCursor = before.Format(time.RFC3339Nano)
@@ -116,8 +122,9 @@ func (c *Client) TimelineWindow(ctx context.Context, before time.Time, domain st
116122 return request[*entity.HistoryWindow](c, ctx, "history_timeline_window", struct {
117123 RequestID string `json:"requestId"`
118124 Before string `json:"before,omitempty"`
125+ BeforeID int64 `json:"beforeId,omitempty"`
119126 Domain string `json:"domain,omitempty"`
120- }{RequestID: nextRequestID(), Before: beforeCursor, Domain: strings.TrimSpace(domain)})
127+ }{RequestID: nextRequestID(), Before: beforeCursor, BeforeID: beforeID, Domain: strings.TrimSpace(domain)})
121128 }
122129
123130 func (c *Client) Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error) {
M internal/infrastructure/systemviewsbridge/client_test.go +54 -2
@@ -135,7 +135,7 @@ func TestClientTimelineWindowOmitsZeroCursor(t *testing.T) {
135135 native := newTransportRecorder(t, true, []byte(`{"requestId":"req-window","success":true}`))
136136 client := NewClient(native, nil)
137137
138- _, err := client.TimelineWindow(context.Background(), time.Time{}, "")
138+ _, err := client.TimelineWindow(context.Background(), time.Time{}, 0, "")
139139 if err != nil {
140140 t.Fatalf("TimelineWindow() error = %v", err)
141141 }
@@ -152,7 +152,59 @@ func TestClientTimelineWindowOmitsZeroCursor(t *testing.T) {
152152 t.Fatalf("unmarshal payload: %v", err)
153153 }
154154 if _, ok := payload["before"]; ok {
155- t.Fatalf("zero TimelineWindow cursor should be omitted, payload = %s", msg.Payload)
155+ t.Fatalf("zero TimelineWindow cursor should be omit before, payload = %s", msg.Payload)
156+ }
157+ if _, ok := payload["beforeId"]; ok {
158+ t.Fatalf("zero TimelineWindow cursor should omit beforeId, payload = %s", msg.Payload)
159+ }
160+}
161+
162+func TestClientTimelineWindowSendsCompleteCursor(t *testing.T) {
163+ t.Parallel()
164+
165+ native := newTransportRecorder(t, true, []byte(`{"requestId":"req-window","success":true}`))
166+ client := NewClient(native, nil)
167+ before := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC)
168+
169+ _, err := client.TimelineWindow(context.Background(), before, 42, "example.com")
170+ if err != nil {
171+ t.Fatalf("TimelineWindow() error = %v", err)
172+ }
173+
174+ var msg port.WebUIMessage
175+ if err := json.Unmarshal(native.last, &msg); err != nil {
176+ t.Fatalf("unmarshal sent envelope: %v", err)
177+ }
178+ var payload map[string]any
179+ if err := json.Unmarshal(msg.Payload, &payload); err != nil {
180+ t.Fatalf("unmarshal payload: %v", err)
181+ }
182+ if payload["before"] != before.Format(time.RFC3339Nano) {
183+ t.Fatalf("before = %v, want %s", payload["before"], before.Format(time.RFC3339Nano))
184+ }
185+ if payload["beforeId"] != float64(42) {
186+ t.Fatalf("beforeId = %v, want 42", payload["beforeId"])
187+ }
188+}
189+
190+func TestClientTimelineWindowRejectsPartialCursor(t *testing.T) {
191+ t.Parallel()
192+
193+ native := newTransportRecorder(t, true, []byte(`{"requestId":"req-window","success":true}`))
194+ client := NewClient(native, nil)
195+ before := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC)
196+
197+ if _, err := client.TimelineWindow(context.Background(), before, 0, ""); err == nil {
198+ t.Fatal("TimelineWindow(before without beforeID) error = nil")
199+ }
200+ if _, err := client.TimelineWindow(context.Background(), time.Time{}, 42, ""); err == nil {
201+ t.Fatal("TimelineWindow(beforeID without before) error = nil")
202+ }
203+ if _, err := client.TimelineWindow(context.Background(), time.Time{}, -1, ""); err == nil {
204+ t.Fatal("TimelineWindow(negative beforeID without before) error = nil")
205+ }
206+ if native.called {
207+ t.Fatal("TimelineWindow sent request for invalid partial cursor")
156208 }
157209 }
158210
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 +136 -0
@@ -2,17 +2,23 @@ package ui
22
33 import (
44 "context"
5+ "reflect"
6+ "sync"
57 "testing"
8+ "unsafe"
69
10+ "github.com/bnema/dumber/internal/application/dto"
711 "github.com/bnema/dumber/internal/application/usecase"
812 "github.com/bnema/dumber/internal/domain/entity"
913 "github.com/bnema/dumber/internal/infrastructure/config"
14+ "github.com/bnema/dumber/internal/shared/syncdispatch"
1015 "github.com/bnema/dumber/internal/ui/component"
1116 "github.com/bnema/dumber/internal/ui/coordinator"
1217 contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"
1318 "github.com/bnema/dumber/internal/ui/dispatcher"
1419 "github.com/bnema/dumber/internal/ui/input"
1520 "github.com/bnema/dumber/internal/ui/window"
21+ "github.com/bnema/puregotk/v4/glib"
1622 "github.com/stretchr/testify/assert"
1723 "github.com/stretchr/testify/require"
1824 )
@@ -889,3 +895,133 @@ func TestBrowserWindow_ApplySidebarWidthConfig_NilDepsIsSafe(t *testing.T) {
889895 app := &App{deps: nil}
890896 require.NotPanics(t, func() { bw.applySidebarWidthConfig(app) })
891897 }
898+
899+type recordingHistorySidebarReloader struct {
900+ calls int
901+ reasons []string
902+}
903+
904+func (r *recordingHistorySidebarReloader) RequestReloadIfVisible(reason string) {
905+ r.calls++
906+ r.reasons = append(r.reasons, reason)
907+}
908+
909+func TestBrowserWindow_ClearShellStateClearsHistorySidebarReloader(t *testing.T) {
910+ reloader := &recordingHistorySidebarReloader{}
911+ bw := &browserWindow{
912+ historySidebar: &component.HistorySidebar{},
913+ historySidebarReloader: reloader,
914+ }
915+
916+ bw.clearShellState()
917+
918+ assert.Nil(t, bw.historySidebar)
919+ assert.Nil(t, bw.historySidebarReloader)
920+}
921+
922+func TestHistoryChangeAdapter_DispatchesBeforeRefreshingSidebars(t *testing.T) {
923+ reloader := &recordingHistorySidebarReloader{}
924+ bw := &browserWindow{id: "window-1", sidebarVisible: true, historySidebarReloader: reloader}
925+ var dispatched bool
926+ var directCalls int
927+ app := &App{
928+ browserWindows: map[string]*browserWindow{bw.id: bw},
929+ dispatchOnMainThread: func(label string, fn func()) syncdispatch.SyncDispatchResult {
930+ dispatched = true
931+ assert.Equal(t, "ui.history_sidebar_refresh", label)
932+ assert.Zero(t, reloader.calls, "sidebar must not be touched before main-thread dispatch runs")
933+ directCalls = reloader.calls
934+ fn()
935+ return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchCompleted}
936+ },
937+ }
938+
939+ newHistoryChangeAdapter(app).OnHistoryChanged(t.Context(), dto.HistoryChange{Reasons: []dto.HistoryChangeReason{dto.HistoryChangeReasonVisit}})
940+
941+ assert.True(t, dispatched)
942+ assert.Zero(t, directCalls)
943+ assert.Equal(t, 1, reloader.calls)
944+ assert.Equal(t, []string{"visit"}, reloader.reasons)
945+}
946+
947+func TestHistoryChangeAdapter_SkipsRefreshWithoutDispatcher(t *testing.T) {
948+ reloader := &recordingHistorySidebarReloader{}
949+ bw := &browserWindow{id: "window-1", sidebarVisible: true, historySidebarReloader: reloader}
950+ app := &App{browserWindows: map[string]*browserWindow{bw.id: bw}}
951+
952+ newHistoryChangeAdapter(app).OnHistoryChanged(
953+ t.Context(),
954+ dto.HistoryChange{Reasons: []dto.HistoryChangeReason{dto.HistoryChangeReasonVisit}},
955+ )
956+
957+ assert.Zero(t, reloader.calls)
958+}
959+
960+func TestApp_RefreshVisibleHistorySidebars_OnlyVisibleInitializedWindows(t *testing.T) {
961+ visibleOne := &recordingHistorySidebarReloader{}
962+ visibleTwo := &recordingHistorySidebarReloader{}
963+ hidden := &recordingHistorySidebarReloader{}
964+ fallbackSidebar := &component.HistorySidebar{}
965+ var fallbackMu sync.Mutex
966+ var fallbackIdle []glib.SourceFunc
967+ var fallbackTimers []glib.SourceFunc
968+ setComponentHistorySidebarField(t, fallbackSidebar, "visible", true)
969+ setComponentHistorySidebarField(t, fallbackSidebar, "idleScheduler", func(cb glib.SourceFunc) {
970+ fallbackMu.Lock()
971+ defer fallbackMu.Unlock()
972+ fallbackIdle = append(fallbackIdle, cb)
973+ })
974+ setComponentHistorySidebarField(t, fallbackSidebar, "timeoutAdd", func(_ uint, cb glib.SourceFunc) uint {
975+ fallbackMu.Lock()
976+ defer fallbackMu.Unlock()
977+ fallbackTimers = append(fallbackTimers, cb)
978+ return uint(100 + len(fallbackTimers))
979+ })
980+ app := &App{browserWindows: map[string]*browserWindow{
981+ "visible-1": {id: "visible-1", sidebarVisible: true, historySidebarReloader: visibleOne},
982+ "hidden": {id: "hidden", sidebarVisible: false, historySidebarReloader: hidden},
983+ "uninitialized": {id: "uninitialized", sidebarVisible: true},
984+ "visible-2": {id: "visible-2", sidebarVisible: true, historySidebarReloader: visibleTwo},
985+ "visible-fallback": {id: "visible-fallback", sidebarVisible: true, historySidebar: fallbackSidebar},
986+ "destroyed-no-op": nil,
987+ }}
988+
989+ require.NotPanics(t, func() {
990+ app.refreshVisibleHistorySidebars(dto.HistoryChange{Reasons: []dto.HistoryChangeReason{dto.HistoryChangeReasonClear}})
991+ })
992+
993+ assert.Equal(t, 1, visibleOne.calls)
994+ assert.Equal(t, 1, visibleTwo.calls)
995+ assert.Zero(t, hidden.calls)
996+ assert.Equal(t, []string{"clear"}, visibleOne.reasons)
997+ assert.Equal(t, []string{"clear"}, visibleTwo.reasons)
998+
999+ fallbackMu.Lock()
1000+ require.Len(t, fallbackIdle, 1)
1001+ idleCb := fallbackIdle[0]
1002+ fallbackMu.Unlock()
1003+ assert.False(t, idleCb(0))
1004+
1005+ fallbackMu.Lock()
1006+ require.Len(t, fallbackTimers, 1)
1007+ timerCb := fallbackTimers[0]
1008+ fallbackMu.Unlock()
1009+ assert.False(t, timerCb(0))
1010+ assert.True(t, getComponentHistorySidebarBoolField(t, fallbackSidebar, "loadStarted"))
1011+}
1012+
1013+func setComponentHistorySidebarField(t *testing.T, hs *component.HistorySidebar, name string, value any) {
1014+ t.Helper()
1015+ rv := reflect.ValueOf(hs).Elem()
1016+ field := rv.FieldByName(name)
1017+ require.Truef(t, field.IsValid(), "component.HistorySidebar.%s field missing", name)
1018+ reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem().Set(reflect.ValueOf(value))
1019+}
1020+
1021+func getComponentHistorySidebarBoolField(t *testing.T, hs *component.HistorySidebar, name string) bool {
1022+ t.Helper()
1023+ rv := reflect.ValueOf(hs).Elem()
1024+ field := rv.FieldByName(name)
1025+ require.Truef(t, field.IsValid(), "component.HistorySidebar.%s field missing", name)
1026+ return reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem().Bool()
1027+}
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 +49 -7
@@ -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,27 @@ func (hs *HistorySidebar) Show() {
275304 // Hide hides the sidebar.
276305 func (hs *HistorySidebar) Hide() {
277306 hs.mu.Lock()
278- defer hs.mu.Unlock()
279-
280- if hs.outerBox == nil || hs.destroyed {
307+ if hs.destroyed {
308+ hs.mu.Unlock()
281309 return
282310 }
283311
284- hs.outerBox.SetVisible(false)
312+ reloadTimerID := hs.cancelReloadDebounceLocked()
313+ tickerID := hs.relativeTimeTicker
314+ hs.relativeTimeTicker = 0
315+ hs.clearRelativeTimeBindingsLocked()
316+ if hs.outerBox != nil {
317+ hs.outerBox.SetVisible(false)
318+ }
285319 hs.visible = false
320+ hs.mu.Unlock()
321+
322+ if reloadTimerID != 0 {
323+ hs.removeSource(reloadTimerID)
324+ }
325+ if tickerID != 0 {
326+ hs.removeSource(tickerID)
327+ }
286328 }
287329
288330 // IsVisible returns whether the sidebar is visible.
M internal/ui/component/history_sidebar_loading_search.go +12 -9
@@ -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,20 +280,20 @@ 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 {
287287 hs.applyFilter()
288288 return false
289289 })
290- timerID := glib.TimeoutAdd(uint(sidebarSearchDebounceMs), &filterCb, 0)
290+ timerID := hs.addTimeout(sidebarSearchDebounceMs, filterCb)
291291
292292 hs.mu.Lock()
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 +282 -0
@@ -0,0 +1,282 @@
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_HideCancelsReloadTickerAndClearsBindingsWithoutDestroying(t *testing.T) {
170+ h := newSidebarReactivityHarness()
171+ h.hs.visible = true
172+ h.hs.reloadDebounceTimer = 61
173+ h.hs.relativeTimeTicker = 62
174+ h.hs.relativeTimeLabelBinds = []relativeTimeLabelBinding{{lastVisited: h.hs.currentTime()}}
175+
176+ h.hs.Hide()
177+
178+ h.hs.mu.RLock()
179+ destroyed := h.hs.destroyed
180+ visible := h.hs.visible
181+ reloadTimer := h.hs.reloadDebounceTimer
182+ ticker := h.hs.relativeTimeTicker
183+ bindings := h.hs.relativeTimeLabelBinds
184+ h.hs.mu.RUnlock()
185+ assert.False(t, destroyed)
186+ assert.False(t, visible)
187+ assert.Zero(t, reloadTimer)
188+ assert.Zero(t, ticker)
189+ assert.Empty(t, bindings)
190+ assert.Contains(t, h.removed, uint(61))
191+ assert.Contains(t, h.removed, uint(62))
192+}
193+
194+func TestHistorySidebar_RelativeTimeBindingsLifecycleAndTicker(t *testing.T) {
195+ h := newSidebarReactivityHarness()
196+ h.hs.visible = true
197+ h.hs.bindRelativeTimeLabel(nil, h.hs.currentTime())
198+ assert.Empty(t, h.hs.relativeTimeLabelBinds, "nil labels are skipped")
199+ h.hs.relativeTimeLabelBinds = []relativeTimeLabelBinding{{lastVisited: h.hs.currentTime().Add(-time.Minute)}}
200+ h.hs.mu.Lock()
201+ h.hs.clearRelativeTimeBindingsLocked()
202+ h.hs.mu.Unlock()
203+ assert.Empty(t, h.hs.relativeTimeLabelBinds)
204+
205+ h.hs.startRelativeTimeTicker()
206+ assert.NotZero(t, h.hs.relativeTimeTicker)
207+ assert.Len(t, h.timers, 1)
208+ assert.Equal(t, 1, h.runIdle(), "initial label refresh is scheduled through idle path")
209+
210+ ticker := h.hs.relativeTimeTicker
211+ h.hs.visible = false
212+ h.hs.relativeTimeTicker = 0
213+ h.hs.removeSource(ticker)
214+ assert.Contains(t, h.removed, ticker)
215+}
216+
217+func TestHistorySidebar_RelativeTimeTickerIgnoresSupersededTimer(t *testing.T) {
218+ h := newSidebarReactivityHarness()
219+ h.hs.visible = true
220+
221+ h.hs.startRelativeTimeTicker()
222+ firstID, staleCB := h.singleTimer(t)
223+ require.Equal(t, 1, h.runIdle(), "clear initial refresh")
224+
225+ h.hs.startRelativeTimeTicker()
226+ currentID, currentCB := h.singleTimer(t)
227+ require.NotEqual(t, firstID, currentID)
228+ require.Equal(t, currentID, h.hs.relativeTimeTicker)
229+ require.Equal(t, 1, h.runIdle(), "clear replacement initial refresh")
230+
231+ keep := staleCB(0)
232+
233+ assert.False(t, keep, "stale ticker should stop")
234+ assert.Equal(t, currentID, h.hs.relativeTimeTicker)
235+ assert.Zero(t, h.runIdle(), "stale ticker should not schedule refresh")
236+
237+ keep = currentCB(0)
238+ assert.True(t, keep, "current ticker should keep running while visible")
239+ assert.Equal(t, 1, h.runIdle(), "current ticker schedules refresh")
240+}
241+
242+func TestHistorySidebar_RelativeTimeDayBoundaryRequestsReloadNoRepoCall(t *testing.T) {
243+ h := newSidebarReactivityHarness()
244+ day1 := time.Date(2026, 6, 24, 23, 59, 0, 0, time.UTC)
245+ day2 := day1.Add(2 * time.Minute)
246+ now := day1
247+ h.hs.now = func() time.Time { return now }
248+ h.hs.visible = true
249+ h.hs.relativeTimeDayKey = h.hs.currentDayKey()
250+ h.hs.relativeTimeDayKeySet = true
251+ h.hs.relativeTimeLabelBinds = []relativeTimeLabelBinding{{lastVisited: day1.Add(-time.Hour)}}
252+ now = day2
253+ h.hs.updateRelativeTimeLabelsOnMain()
254+ assert.False(t, h.hs.loadStarted, "day boundary should not reload synchronously")
255+ assert.Equal(t, 1, h.runIdle(), "day boundary schedules debounced reload via RequestReloadIfVisible")
256+ assert.False(t, h.hs.loadStarted, "day boundary should wait for debounce before loading")
257+ assert.Equal(t, h.hs.currentDayKey(), h.hs.relativeTimeDayKey)
258+ assert.Len(t, h.timers, 1)
259+}
260+
261+func TestRelativeTimeAtDeterministic(t *testing.T) {
262+ now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
263+ assert.Equal(t, "1m ago", relativeTimeAt(now.Add(-time.Minute), now))
264+ assert.Equal(t, "2h ago", relativeTimeAt(now.Add(-2*time.Hour), now))
265+}
266+
267+func TestHistorySidebar_RelativeRefreshDoesNotTouchHistoryRepo(t *testing.T) {
268+ h := newSidebarReactivityHarness()
269+ h.hs.historyUC = nil
270+ h.hs.visible = true
271+ h.hs.relativeTimeLabelBinds = []relativeTimeLabelBinding{{lastVisited: h.hs.currentTime().Add(-time.Minute)}}
272+ h.hs.updateRelativeTimeLabelsOnMain()
273+ assert.Empty(t, h.timers, "label-only refresh does not schedule reload/repo work without day boundary")
274+}
275+
276+func TestHistorySidebar_ReloadStillPreservesDisplayRowsSemantics(t *testing.T) {
277+ hs := newTestSidebarSearchHarness()
278+ entry := &entity.HistoryEntry{URL: "https://example.com", LastVisited: time.Now()}
279+ hs.setDisplayGroupsLocked(groupHistoryByDay([]*entity.HistoryEntry{entry}))
280+ require.Len(t, hs.displayRows, 2)
281+ assert.Equal(t, entry.URL, hs.entryURLAtIndex(1))
282+}
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+}
M internal/ui/systemviews/app.go +22 -3
@@ -38,6 +38,7 @@ type App struct {
3838 historyOffset int
3939 historyWindowBefore time.Time
4040 historyWindowAfter time.Time
41+ historyCursorID int64
4142 historyHasMore bool
4243 historyNotice string
4344 historyError string
@@ -571,6 +572,7 @@ type historyRouteSnapshot struct {
571572 offset int
572573 windowBefore time.Time
573574 windowAfter time.Time
575+ cursorID int64
574576 notice string
575577 error string
576578 }
@@ -581,6 +583,7 @@ type historyRouteResult struct {
581583 domains []*entity.DomainStat
582584 windowBefore time.Time
583585 windowAfter time.Time
586+ cursorID int64
584587 hasMore bool
585588 html string
586589 }
@@ -595,6 +598,7 @@ func (a *App) currentHistoryRouteSnapshotLocked() historyRouteSnapshot {
595598 offset: a.historyOffset,
596599 windowBefore: a.historyWindowBefore,
597600 windowAfter: a.historyWindowAfter,
601+ cursorID: a.historyCursorID,
598602 notice: a.historyNotice,
599603 error: a.historyError,
600604 }
@@ -633,8 +637,7 @@ func (a *App) renderHistoryRouteSnapshot(ctx context.Context, snapshot historyRo
633637 }
634638 if window != nil {
635639 result.windowBefore = window.Before
636- result.windowAfter = window.After
637- result.hasMore = window.HasMore
640+ result.windowAfter, result.cursorID, result.hasMore = historyWindowCursor(window)
638641 }
639642 data := historyRenderData{
640643 Entries: entries,
@@ -662,6 +665,20 @@ func (a *App) renderHistoryRouteSnapshot(ctx context.Context, snapshot historyRo
662665 return result, nil
663666 }
664667
668+func historyWindowCursor(window *entity.HistoryWindow) (time.Time, int64, bool) {
669+ if window == nil || !window.HasMore {
670+ return time.Time{}, 0, false
671+ }
672+ cursorTime := window.CursorLastVisited
673+ if cursorTime.IsZero() {
674+ cursorTime = window.After
675+ }
676+ if cursorTime.IsZero() || window.CursorID <= 0 {
677+ return time.Time{}, 0, false
678+ }
679+ return cursorTime, window.CursorID, true
680+}
681+
665682 func loadHistoryWindowSnapshot(ctx context.Context, snapshot historyRouteSnapshot) (*entity.HistoryWindow, []*entity.HistoryEntry, error) {
666683 query := strings.TrimSpace(snapshot.query)
667684 domain := strings.TrimSpace(snapshot.domainFilter)
@@ -669,7 +686,7 @@ func loadHistoryWindowSnapshot(ctx context.Context, snapshot historyRouteSnapsho
669686 entries, err := snapshot.history.Search(ctx, query, historySearchLimit)
670687 return nil, entries, err
671688 }
672- window, err := snapshot.history.TimelineWindow(ctx, snapshot.windowBefore, domain)
689+ window, err := snapshot.history.TimelineWindow(ctx, snapshot.windowAfter, snapshot.cursorID, domain)
673690 if err != nil {
674691 return nil, nil, err
675692 }
@@ -689,6 +706,7 @@ func (a *App) commitHistoryRouteResultLocked(result historyRouteResult) {
689706 a.historyDomainStats = result.domains
690707 a.historyWindowBefore = result.windowBefore
691708 a.historyWindowAfter = result.windowAfter
709+ a.historyCursorID = result.cursorID
692710 a.historyHasMore = result.hasMore
693711 a.renderedHTML = result.html
694712 }
@@ -812,6 +830,7 @@ func (a *App) resetRouteState() {
812830 a.historyOffset = 0
813831 a.historyWindowBefore = time.Time{}
814832 a.historyWindowAfter = time.Time{}
833+ a.historyCursorID = 0
815834 a.historyHasMore = false
816835 a.historyNotice = ""
817836 a.historyError = ""
M internal/ui/systemviews/app_test.go +47 -9
@@ -213,16 +213,18 @@ func TestAppHandleHistoryLoadMoreAppendsOlderWindow(t *testing.T) {
213213
214214 cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC)
215215 history := &recordingHistoryService{window: &entity.HistoryWindow{
216- Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example", Title: "Older entry", LastVisited: cursor.Add(-time.Hour)}},
217- Before: cursor,
218- After: cursor.Add(-24 * time.Hour),
219- HasMore: true,
216+ Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example", Title: "Older entry", LastVisited: cursor.Add(-time.Hour)}},
217+ Before: cursor,
218+ CursorLastVisited: cursor.Add(-time.Hour),
219+ CursorID: 2,
220+ HasMore: true,
220221 }}
221222 dom := &recordingActionDOM{}
222223 app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
223224 app.currentRoute = RouteHistory
224225 app.historyEntries = []*entity.HistoryEntry{{ID: 1, URL: "https://newer.example", Title: "Newer entry", LastVisited: cursor}}
225226 app.historyWindowAfter = cursor
227+ app.historyCursorID = 1
226228 app.historyHasMore = true
227229
228230 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
@@ -230,6 +232,9 @@ func TestAppHandleHistoryLoadMoreAppendsOlderWindow(t *testing.T) {
230232 Data: map[string]string{"before": cursor.Format(time.RFC3339Nano)},
231233 }))
232234 assert.Equal(t, cursor, history.windowBefore)
235+ assert.Equal(t, int64(1), history.windowBeforeID)
236+ assert.Equal(t, int64(2), app.historyCursorID)
237+ assert.Equal(t, cursor.Add(-time.Hour), app.historyWindowAfter)
233238 assert.Len(t, app.historyEntries, 2)
234239 assert.Contains(t, dom.AppendedHTML(), "Older entry")
235240 assert.Contains(t, dom.AppendedHTML(), `data-sv-action="history.loadMore"`)
@@ -241,16 +246,18 @@ func TestAppHandleHistoryLoadMoreRemountsWhenAppendUnavailable(t *testing.T) {
241246
242247 cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC)
243248 history := &recordingHistoryService{window: &entity.HistoryWindow{
244- Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example", Title: "Older entry", LastVisited: cursor.Add(-time.Hour)}},
245- Before: cursor,
246- After: cursor.Add(-24 * time.Hour),
247- HasMore: true,
249+ Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example", Title: "Older entry", LastVisited: cursor.Add(-time.Hour)}},
250+ Before: cursor,
251+ CursorLastVisited: cursor.Add(-time.Hour),
252+ CursorID: 2,
253+ HasMore: true,
248254 }}
249255 dom := &recordingDOM{}
250256 app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
251257 app.currentRoute = RouteHistory
252258 app.historyEntries = []*entity.HistoryEntry{{ID: 1, URL: "https://newer.example", Title: "Newer entry", LastVisited: cursor}}
253259 app.historyWindowAfter = cursor
260+ app.historyCursorID = 1
254261 app.historyHasMore = true
255262
256263 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
@@ -258,11 +265,40 @@ func TestAppHandleHistoryLoadMoreRemountsWhenAppendUnavailable(t *testing.T) {
258265 Data: map[string]string{"before": cursor.Format(time.RFC3339Nano)},
259266 }))
260267 assert.Equal(t, cursor, history.windowBefore)
268+ assert.Equal(t, int64(1), history.windowBeforeID)
261269 assert.Len(t, app.historyEntries, 2)
262270 assert.Contains(t, dom.HTML(), "Older entry")
263271 assert.Contains(t, dom.HTML(), `data-sv-action="history.loadMore"`)
264272 }
265273
274+func TestAppHandleHistoryLoadMoreDisablesPartialNextCursor(t *testing.T) {
275+ t.Parallel()
276+
277+ cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC)
278+ history := &recordingHistoryService{window: &entity.HistoryWindow{
279+ Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example", Title: "Older entry", LastVisited: cursor.Add(-time.Hour)}},
280+ Before: cursor,
281+ After: cursor.Add(-24 * time.Hour),
282+ HasMore: true,
283+ }}
284+ dom := &recordingActionDOM{}
285+ app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
286+ app.currentRoute = RouteHistory
287+ app.historyEntries = []*entity.HistoryEntry{{ID: 1, URL: "https://newer.example", Title: "Newer entry", LastVisited: cursor}}
288+ app.historyWindowAfter = cursor
289+ app.historyCursorID = 1
290+ app.historyHasMore = true
291+
292+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
293+ Action: historyActionLoadMore,
294+ Data: map[string]string{"before": cursor.Format(time.RFC3339Nano)},
295+ }))
296+ assert.False(t, app.historyHasMore)
297+ assert.Zero(t, app.historyCursorID)
298+ assert.True(t, app.historyWindowAfter.IsZero())
299+ assert.NotContains(t, dom.AppendedHTML(), `data-sv-action="history.loadMore"`)
300+}
301+
266302 func TestAppHandleHistoryLoadMoreIgnoresStaleCursor(t *testing.T) {
267303 t.Parallel()
268304
@@ -1072,6 +1108,7 @@ type recordingHistoryService struct {
10721108 statsCalled bool
10731109 window *entity.HistoryWindow
10741110 windowBefore time.Time
1111+ windowBeforeID int64
10751112 windowDomain string
10761113 analyticsCalled bool
10771114
@@ -1101,9 +1138,10 @@ func (s *recordingHistoryService) TimelineByDomain(_ context.Context, domain str
11011138 return s.entries, s.err
11021139 }
11031140
1104-func (s *recordingHistoryService) TimelineWindow(_ context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) {
1141+func (s *recordingHistoryService) TimelineWindow(_ context.Context, before time.Time, beforeID int64, domain string) (*entity.HistoryWindow, error) {
11051142 s.called = true
11061143 s.windowBefore = before
1144+ s.windowBeforeID = beforeID
11071145 s.windowDomain = domain
11081146 if s.timelineStarted != nil {
11091147 s.startOnce.Do(func() { close(s.timelineStarted) })
M internal/ui/systemviews/history_actions.go +74 -45
@@ -6,6 +6,9 @@ import (
66 "strconv"
77 "strings"
88 "time"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/domain/entity"
912 )
1013
1114 const (
@@ -167,54 +170,84 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error {
167170 return nil
168171 }
169172
173+type historyLoadMoreRequest struct {
174+ before time.Time
175+ beforeID int64
176+ domain string
177+ history port.SystemviewHistoryService
178+ dom DOM
179+}
180+
170181 func (a *App) handleHistoryLoadMore(ctx context.Context, event DOMAction) error {
171182 a.lockState()
172- if a.deps.History == nil {
173- a.unlockState()
174- return fmt.Errorf("history service not configured")
183+ req, ok, err := a.historyLoadMoreRequestLocked(event)
184+ a.unlockState()
185+ if err != nil || !ok {
186+ return err
175187 }
176- if strings.TrimSpace(a.historyQuery) != "" || !a.historyHasMore {
188+
189+ window, err := req.history.TimelineWindow(ctx, req.before, req.beforeID, req.domain)
190+ if err != nil {
191+ return err
192+ }
193+ if window == nil {
194+ a.lockState()
195+ a.historyHasMore = false
177196 a.unlockState()
178197 return nil
179198 }
199+
200+ a.lockState()
201+ fragment, fallbackHTML := a.appendHistoryWindowLocked(window, req.dom)
202+ a.unlockState()
203+
204+ if appender, ok := req.dom.(DOMHistoryTimelineAppender); ok {
205+ return appender.AppendHistoryTimeline(fragment)
206+ }
207+ return a.mountHTML(fallbackHTML)
208+}
209+
210+func (a *App) historyLoadMoreRequestLocked(event DOMAction) (historyLoadMoreRequest, bool, error) {
211+ if a.deps.History == nil {
212+ return historyLoadMoreRequest{}, false, fmt.Errorf("history service not configured")
213+ }
214+ if strings.TrimSpace(a.historyQuery) != "" || !a.historyHasMore {
215+ return historyLoadMoreRequest{}, false, nil
216+ }
217+
180218 before := a.historyWindowAfter
219+ beforeID := a.historyCursorID
181220 if requested := strings.TrimSpace(event.Data["before"]); requested != "" {
182221 parsed, err := time.Parse(time.RFC3339Nano, requested)
183222 if err != nil {
184- a.unlockState()
185- return fmt.Errorf("invalid history cursor")
223+ return historyLoadMoreRequest{}, false, fmt.Errorf("invalid history cursor")
186224 }
187225 if !before.IsZero() && !parsed.Equal(before) {
188- a.unlockState()
189- return nil
226+ return historyLoadMoreRequest{}, false, nil
190227 }
191228 before = parsed
192229 }
193- if before.IsZero() {
194- before = time.Now()
195- }
196- domain := a.historyDomainFilter
197- history := a.deps.History
198- dom := a.deps.DOM
199- a.unlockState()
200-
201- window, err := history.TimelineWindow(ctx, before, domain)
202- if err != nil {
203- return err
204- }
205- if window == nil {
206- a.lockState()
230+ if before.IsZero() || beforeID <= 0 {
231+ a.historyWindowAfter = time.Time{}
232+ a.historyCursorID = 0
207233 a.historyHasMore = false
208- a.unlockState()
209- return nil
234+ return historyLoadMoreRequest{}, false, nil
210235 }
211236
212- a.lockState()
237+ return historyLoadMoreRequest{
238+ before: before,
239+ beforeID: beforeID,
240+ domain: a.historyDomainFilter,
241+ history: a.deps.History,
242+ dom: a.deps.DOM,
243+ }, true, nil
244+}
245+
246+func (a *App) appendHistoryWindowLocked(window *entity.HistoryWindow, dom DOM) (string, string) {
213247 appendSkipFirstDate := lastHistoryDateKey(a.historyEntries)
214248 a.historyEntries = append(a.historyEntries, window.Entries...)
215249 a.historyWindowBefore = window.Before
216- a.historyWindowAfter = window.After
217- a.historyHasMore = window.HasMore
250+ a.historyWindowAfter, a.historyCursorID, a.historyHasMore = historyWindowCursor(window)
218251 data := historyRenderData{
219252 Entries: window.Entries,
220253 Stats: a.historyStats,
@@ -231,32 +264,28 @@ func (a *App) handleHistoryLoadMore(ctx context.Context, event DOMAction) error
231264 Error: a.historyError,
232265 }
233266 fragment := historyTimelineAppendHTML(data)
234- var fallbackHTML string
235- if _, ok := dom.(DOMHistoryTimelineAppender); !ok {
236- fullData := data
237- fullData.Entries = a.historyEntries
238- title := historyDocumentTitle(fullData)
239- a.renderedHTML = renderAppFrame(renderedPage{
240- route: RouteHistory,
241- title: title,
242- subtitle: "History",
243- subtitleDetail: historyTitleDetail(fullData),
244- body: historyHTML(fullData),
245- }, a.shellTheme)
246- fallbackHTML = a.renderedHTML
267+ if _, ok := dom.(DOMHistoryTimelineAppender); ok {
268+ return fragment, ""
247269 }
248- a.unlockState()
249270
250- if appender, ok := dom.(DOMHistoryTimelineAppender); ok {
251- return appender.AppendHistoryTimeline(fragment)
252- }
253- return a.mountHTML(fallbackHTML)
271+ fullData := data
272+ fullData.Entries = a.historyEntries
273+ title := historyDocumentTitle(fullData)
274+ a.renderedHTML = renderAppFrame(renderedPage{
275+ route: RouteHistory,
276+ title: title,
277+ subtitle: "History",
278+ subtitleDetail: historyTitleDetail(fullData),
279+ body: historyHTML(fullData),
280+ }, a.shellTheme)
281+ return fragment, a.renderedHTML
254282 }
255283
256284 func (a *App) resetHistoryWindowState() {
257285 a.historyOffset = 0
258286 a.historyWindowBefore = time.Time{}
259287 a.historyWindowAfter = time.Time{}
288+ a.historyCursorID = 0
260289 a.historyHasMore = false
261290 }
262291
A scripts/verify_aur_metadata.sh +46 -0
@@ -0,0 +1,46 @@
1+#!/usr/bin/env bash
2+set -euo pipefail
3+
4+if ! command -v makepkg >/dev/null 2>&1; then
5+ echo "makepkg is required to verify AUR metadata" >&2
6+ exit 1
7+fi
8+
9+for pkgdir in aur/dumber-browser-bin aur/dumber-browser-git; do
10+ tmp=$(mktemp)
11+ trap 'rm -f "$tmp"' EXIT
12+ (cd "$pkgdir" && makepkg --printsrcinfo > "$tmp")
13+ diff -u "$pkgdir/.SRCINFO" "$tmp"
14+ grep -qxE '[[:space:]]*depends = cef' "$pkgdir/.SRCINFO"
15+ grep -qxE '[[:space:]]*depends = cef' "$tmp"
16+ for metadata in "$pkgdir/.SRCINFO" "$tmp"; do
17+ if grep -qxE '[[:space:]]*depends = cef-vaapi(-bin)?([<>=].*)?' "$metadata"; then
18+ echo "cef-vaapi providers must remain optdepends, not hard dependencies: $metadata" >&2
19+ exit 1
20+ fi
21+ done
22+ grep -q "optdepends = cef-vaapi-bin: Prebuilt CEF provider with VA-API hardware video decoding support" "$pkgdir/.SRCINFO"
23+ grep -q "optdepends = cef-vaapi: Source-built CEF provider with VA-API hardware video decoding support" "$pkgdir/.SRCINFO"
24+ grep -q "optdepends = cef-vaapi-bin: Prebuilt CEF provider with VA-API hardware video decoding support" "$tmp"
25+ grep -q "optdepends = cef-vaapi: Source-built CEF provider with VA-API hardware video decoding support" "$tmp"
26+ rm -f "$tmp"
27+ trap - EXIT
28+ echo "verified $pkgdir"
29+done
30+
31+expected="depends=('gtk4' 'cef' 'webkitgtk-6.0')"
32+matches=$(grep -F -c "$expected" .github/workflows/aur.yml || true)
33+if [[ "$matches" -lt 2 ]]; then
34+ echo "expected dependency string in both AUR workflow package blocks" >&2
35+ exit 1
36+fi
37+
38+for expected in \
39+ "'cef-vaapi-bin: Prebuilt CEF provider with VA-API hardware video decoding support'" \
40+ "'cef-vaapi: Source-built CEF provider with VA-API hardware video decoding support'"; do
41+ matches=$(grep -F -c "$expected" .github/workflows/aur.yml || true)
42+ if [[ "$matches" -lt 2 ]]; then
43+ echo "expected $expected in both AUR workflow package blocks" >&2
44+ exit 1
45+ fi
46+done

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