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:
23 cancel-in-progress: true23 cancel-in-progress: true
24 24
25 steps:25 steps:
26- - uses: actions/checkout@v626+ - uses: actions/checkout@v7
27 with:27 with:
28 ref: ${{ github.event.workflow_run.head_sha }}28 ref: ${{ github.event.workflow_run.head_sha }}
29 fetch-depth: 029 fetch-depth: 0
@@ -52,7 +52,7 @@ jobs:
52 'url="https://github.com/bnema/dumber"' \52 'url="https://github.com/bnema/dumber"' \
53 "license=('MIT')" \53 "license=('MIT')" \
54 "makedepends=('go>=1.26' 'git' 'brotli')" \54 "makedepends=('go>=1.26' 'git' 'brotli')" \
55- "depends=('gtk4' 'webkitgtk-6.0')" \55+ "depends=('gtk4' 'cef' 'webkitgtk-6.0')" \
56 'optdepends=(' \56 'optdepends=(' \
57 " 'gst-plugins-base: Base media codecs'" \57 " 'gst-plugins-base: Base media codecs'" \
58 " 'gst-plugins-good: Additional media codecs'" \58 " 'gst-plugins-good: Additional media codecs'" \
@@ -61,6 +61,8 @@ jobs:
61 " 'gst-libav: FFmpeg-based codecs'" \61 " 'gst-libav: FFmpeg-based codecs'" \
62 " 'gst-plugin-pipewire: PipeWire audio support'" \62 " 'gst-plugin-pipewire: PipeWire audio support'" \
63 " 'gst-plugin-va: Hardware video decoding (VA-API stateless decoders)'" \63 " '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'" \
64 " 'pipewire: Audio/video routing'" \66 " 'pipewire: Audio/video routing'" \
65 " 'pipewire-pulse: PulseAudio replacement'" \67 " 'pipewire-pulse: PulseAudio replacement'" \
66 " 'mesa: VA-API driver for AMD GPUs'" \68 " 'mesa: VA-API driver for AMD GPUs'" \
@@ -121,7 +123,7 @@ jobs:
121 cancel-in-progress: true123 cancel-in-progress: true
122 124
123 steps:125 steps:
124- - uses: actions/checkout@v6126+ - uses: actions/checkout@v7
125 with:127 with:
126 fetch-depth: 0128 fetch-depth: 0
127 129
@@ -184,7 +186,7 @@ jobs:
184 "arch=('x86_64')" \186 "arch=('x86_64')" \
185 'url="https://github.com/bnema/dumber"' \187 'url="https://github.com/bnema/dumber"' \
186 "license=('MIT')" \188 "license=('MIT')" \
187- "depends=('gtk4' 'webkitgtk-6.0')" \189+ "depends=('gtk4' 'cef' 'webkitgtk-6.0')" \
188 'optdepends=(' \190 'optdepends=(' \
189 " 'gst-plugins-base: Base media codecs'" \191 " 'gst-plugins-base: Base media codecs'" \
190 " 'gst-plugins-good: Additional media codecs'" \192 " 'gst-plugins-good: Additional media codecs'" \
@@ -193,6 +195,8 @@ jobs:
193 " 'gst-libav: FFmpeg-based codecs'" \195 " 'gst-libav: FFmpeg-based codecs'" \
194 " 'gst-plugin-pipewire: PipeWire audio support'" \196 " 'gst-plugin-pipewire: PipeWire audio support'" \
195 " 'gst-plugin-va: Hardware video decoding (VA-API stateless decoders)'" \197 " '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'" \
196 " 'pipewire: Audio/video routing'" \200 " 'pipewire: Audio/video routing'" \
197 " 'pipewire-pulse: PulseAudio replacement'" \201 " 'pipewire-pulse: PulseAudio replacement'" \
198 " 'mesa: VA-API driver for AMD GPUs'" \202 " 'mesa: VA-API driver for AMD GPUs'" \
M .github/workflows/ci.yml +13 -8
@@ -5,7 +5,7 @@ permissions:
5 5
6 on:6 on:
7 push:7 push:
8- branches: [main]8+ branches: [main, next]
9 paths:9 paths:
10 - '**.go'10 - '**.go'
11 - 'go.mod'11 - 'go.mod'
@@ -20,15 +20,20 @@ on:
20 jobs:20 jobs:
21 changes:21 changes:
22 runs-on: ubuntu-latest22 runs-on: ubuntu-latest
23+ permissions:
24+ contents: read
25+ # dorny/paths-filter uses PR metadata to compute changed files.
26+ pull-requests: read
23 outputs:27 outputs:
24 ci: ${{ steps.filter.outputs.ci }}28 ci: ${{ steps.filter.outputs.ci }}
25 steps:29 steps:
26- - uses: actions/checkout@v630+ - uses: actions/checkout@v7
27 with:31 with:
28 fetch-depth: 032 fetch-depth: 0
29 - uses: dorny/paths-filter@v433 - uses: dorny/paths-filter@v4
30 id: filter34 id: filter
31 with:35 with:
36+ base: ${{ github.event_name == 'push' && github.ref_name || '' }}
32 filters: |37 filters: |
33 ci:38 ci:
34 - '**.go'39 - '**.go'
@@ -51,7 +56,7 @@ jobs:
51 run: |56 run: |
52 pacman -Syu --noconfirm57 pacman -Syu --noconfirm
53 pacman -S --noconfirm git go webkitgtk-6.0 gtk4 gobject-introspection base-devel brotli58 pacman -S --noconfirm git go webkitgtk-6.0 gtk4 gobject-introspection base-devel brotli
54- - uses: actions/checkout@v659+ - uses: actions/checkout@v7
55 - name: Allow git commands in container checkout60 - name: Allow git commands in container checkout
56 run: git config --global --add safe.directory "$GITHUB_WORKSPACE"61 run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
57 - name: Set up pinned Go toolchain62 - name: Set up pinned Go toolchain
@@ -60,14 +65,14 @@ jobs:
60 go-version-file: go.mod65 go-version-file: go.mod
61 cache: false66 cache: false
62 - name: Cache Go modules67 - name: Cache Go modules
63- uses: actions/cache@v568+ uses: actions/cache@v6
64 with:69 with:
65 path: ~/go/pkg/mod70 path: ~/go/pkg/mod
66 key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}71 key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
67 restore-keys: |72 restore-keys: |
68 ${{ runner.os }}-go-mod-73 ${{ runner.os }}-go-mod-
69 - name: Cache Go build cache74 - name: Cache Go build cache
70- uses: actions/cache@v575+ uses: actions/cache@v6
71 with:76 with:
72 path: ~/.cache/go-build77 path: ~/.cache/go-build
73 key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}78 key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}
@@ -95,21 +100,21 @@ jobs:
95 run: |100 run: |
96 pacman -Syu --noconfirm101 pacman -Syu --noconfirm
97 pacman -S --noconfirm git go webkitgtk-6.0 gtk4 libadwaita gobject-introspection base-devel brotli dbus102 pacman -S --noconfirm git go webkitgtk-6.0 gtk4 libadwaita gobject-introspection base-devel brotli dbus
98- - uses: actions/checkout@v6103+ - uses: actions/checkout@v7
99 - name: Set up pinned Go toolchain104 - name: Set up pinned Go toolchain
100 uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c105 uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c
101 with:106 with:
102 go-version-file: go.mod107 go-version-file: go.mod
103 cache: false108 cache: false
104 - name: Cache Go modules109 - name: Cache Go modules
105- uses: actions/cache@v5110+ uses: actions/cache@v6
106 with:111 with:
107 path: ~/go/pkg/mod112 path: ~/go/pkg/mod
108 key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}113 key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
109 restore-keys: |114 restore-keys: |
110 ${{ runner.os }}-go-mod-115 ${{ runner.os }}-go-mod-
111 - name: Cache Go build cache116 - name: Cache Go build cache
112- uses: actions/cache@v5117+ uses: actions/cache@v6
113 with:118 with:
114 path: ~/.cache/go-build119 path: ~/.cache/go-build
115 key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}120 key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}
M .github/workflows/flatpak.yml +1 -1
@@ -21,7 +21,7 @@ jobs:
21 image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-4821 image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-48
22 options: --privileged22 options: --privileged
23 steps:23 steps:
24- - uses: actions/checkout@v624+ - uses: actions/checkout@v7
25 25
26 - name: Get version26 - name: Get version
27 id: version27 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:
13 release:13 release:
14 runs-on: ubuntu-latest14 runs-on: ubuntu-latest
15 steps:15 steps:
16- - uses: actions/checkout@v616+ - uses: actions/checkout@v7
17 with:17 with:
18 fetch-depth: 018 fetch-depth: 0
19 19
M .mockery.yaml +2 -0
@@ -113,6 +113,8 @@ packages:
113 EntryInputTarget: {}113 EntryInputTarget: {}
114 HomepageFavorites: {}114 HomepageFavorites: {}
115 HomepageHistory: {}115 HomepageHistory: {}
116+ HistoryChangeSink: {}
117+ HistoryMutationCoordinator: {}
116 HistorySidebarHistory: {}118 HistorySidebarHistory: {}
117 AllKeybindingsResetter: {}119 AllKeybindingsResetter: {}
118 KeybindingResetter: {}120 KeybindingResetter: {}
M aur/dumber-browser-bin/.SRCINFO +4 -1
@@ -1,11 +1,12 @@
1 pkgbase = dumber-browser-bin1 pkgbase = dumber-browser-bin
2 pkgdesc = A minimal keyboard-driven browser for tiling WMs2 pkgdesc = A minimal keyboard-driven browser for tiling WMs
3 pkgver = 0.23.23 pkgver = 0.23.2
4- pkgrel = 14+ pkgrel = 2
5 url = https://github.com/bnema/dumber5 url = https://github.com/bnema/dumber
6 arch = x86_646 arch = x86_64
7 license = MIT7 license = MIT
8 depends = gtk48 depends = gtk4
9+ depends = cef
9 depends = webkitgtk-6.010 depends = webkitgtk-6.0
10 optdepends = gst-plugins-base: Base media codecs11 optdepends = gst-plugins-base: Base media codecs
11 optdepends = gst-plugins-good: Additional media codecs12 optdepends = gst-plugins-good: Additional media codecs
@@ -14,6 +15,8 @@ pkgbase = dumber-browser-bin
14 optdepends = gst-libav: FFmpeg-based codecs15 optdepends = gst-libav: FFmpeg-based codecs
15 optdepends = gst-plugin-pipewire: PipeWire audio support16 optdepends = gst-plugin-pipewire: PipeWire audio support
16 optdepends = gst-plugin-va: Hardware video decoding (VA-API stateless decoders)17 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
17 optdepends = pipewire: Audio/video routing20 optdepends = pipewire: Audio/video routing
18 optdepends = pipewire-pulse: PulseAudio replacement21 optdepends = pipewire-pulse: PulseAudio replacement
19 optdepends = mesa: VA-API driver for AMD GPUs22 optdepends = mesa: VA-API driver for AMD GPUs
M aur/dumber-browser-bin/PKGBUILD +4 -2
@@ -1,12 +1,12 @@
1 # Maintainer: bnema <b at bnema dot dev>1 # Maintainer: bnema <b at bnema dot dev>
2 pkgname=dumber-browser-bin2 pkgname=dumber-browser-bin
3 pkgver=0.23.23 pkgver=0.23.2
4-pkgrel=14+pkgrel=2
5 pkgdesc="A minimal keyboard-driven browser for tiling WMs"5 pkgdesc="A minimal keyboard-driven browser for tiling WMs"
6 arch=('x86_64')6 arch=('x86_64')
7 url="https://github.com/bnema/dumber"7 url="https://github.com/bnema/dumber"
8 license=('MIT')8 license=('MIT')
9-depends=('gtk4' 'webkitgtk-6.0')9+depends=('gtk4' 'cef' 'webkitgtk-6.0')
10 optdepends=(10 optdepends=(
11 'gst-plugins-base: Base media codecs'11 'gst-plugins-base: Base media codecs'
12 'gst-plugins-good: Additional media codecs'12 'gst-plugins-good: Additional media codecs'
@@ -15,6 +15,8 @@ optdepends=(
15 'gst-libav: FFmpeg-based codecs'15 'gst-libav: FFmpeg-based codecs'
16 'gst-plugin-pipewire: PipeWire audio support'16 'gst-plugin-pipewire: PipeWire audio support'
17 'gst-plugin-va: Hardware video decoding (VA-API stateless decoders)'17 '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'
18 'pipewire: Audio/video routing'20 'pipewire: Audio/video routing'
19 'pipewire-pulse: PulseAudio replacement'21 'pipewire-pulse: PulseAudio replacement'
20 'mesa: VA-API driver for AMD GPUs'22 'mesa: VA-API driver for AMD GPUs'
M aur/dumber-browser-git/.SRCINFO +2 -1
@@ -18,7 +18,8 @@ pkgbase = dumber-browser-git
18 optdepends = gst-libav: FFmpeg-based codecs18 optdepends = gst-libav: FFmpeg-based codecs
19 optdepends = gst-plugin-pipewire: PipeWire audio support19 optdepends = gst-plugin-pipewire: PipeWire audio support
20 optdepends = gst-plugin-va: Hardware video decoding (VA-API stateless decoders)20 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 support21+ 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
22 optdepends = pipewire: Audio/video routing23 optdepends = pipewire: Audio/video routing
23 optdepends = pipewire-pulse: PulseAudio replacement24 optdepends = pipewire-pulse: PulseAudio replacement
24 optdepends = mesa: VA-API driver for AMD GPUs25 optdepends = mesa: VA-API driver for AMD GPUs
M aur/dumber-browser-git/PKGBUILD +2 -1
@@ -16,7 +16,8 @@ optdepends=(
16 'gst-libav: FFmpeg-based codecs'16 'gst-libav: FFmpeg-based codecs'
17 'gst-plugin-pipewire: PipeWire audio support'17 'gst-plugin-pipewire: PipeWire audio support'
18 'gst-plugin-va: Hardware video decoding (VA-API stateless decoders)'18 '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'
20 'pipewire: Audio/video routing'21 'pipewire: Audio/video routing'
21 'pipewire-pulse: PulseAudio replacement'22 'pipewire-pulse: PulseAudio replacement'
22 'mesa: VA-API driver for AMD GPUs'23 'mesa: VA-API driver for AMD GPUs'
M cmd/dumber/main.go +47 -30
@@ -259,6 +259,7 @@ func runGUI(cfg *config.Config) int {
259 engine.SetHandlerContext(ctx)259 engine.SetHandlerContext(ctx)
260 260
261 useCases := createUseCases(repos, cfg)261 useCases := createUseCases(repos, cfg)
262+ defer useCases.Close()
262 if needsEagerDB {263 if needsEagerDB {
263 handleAutoRestore(ctx, cfg, useCases, browserSession.Session.ID)264 handleAutoRestore(ctx, cfg, useCases, browserSession.Session.ID)
264 }265 }
@@ -322,6 +323,7 @@ func runStandaloneOmnibox() int {
322 }323 }
323 324
324 useCases := createUseCases(repos, cfg)325 useCases := createUseCases(repos, cfg)
326+ defer useCases.Close()
325 uiDeps, err := buildUIDependencies(327 uiDeps, err := buildUIDependencies(
326 ctx,328 ctx,
327 cfg,329 cfg,
@@ -728,21 +730,29 @@ func createLazyRepositories(provider port.DatabaseProvider) *repositories {
728 730
729 // useCases groups application layer use case implementations.731 // useCases groups application layer use case implementations.
730 type useCases struct {732 type useCases struct {
731- tabs *usecase.ManageTabsUseCase733+ tabs *usecase.ManageTabsUseCase
732- panes *usecase.ManagePanesUseCase734+ panes *usecase.ManagePanesUseCase
733- history *usecase.SearchHistoryUseCase735+ history *usecase.SearchHistoryUseCase
734- favorites *usecase.ManageFavoritesUseCase736+ favorites *usecase.ManageFavoritesUseCase
735- zoom *usecase.ManageZoomUseCase737+ zoom *usecase.ManageZoomUseCase
736- permission *usecase.HandlePermissionUseCase738+ permission *usecase.HandlePermissionUseCase
737- navigate *usecase.NavigateUseCase739+ navigate *usecase.NavigateUseCase
738- copyURL *usecase.CopyURLUseCase740+ historyRecorder *usecase.HistoryRecorderUseCase
739- snapshot *usecase.SnapshotSessionUseCase741+ copyURL *usecase.CopyURLUseCase
740- lastRestorable *usecase.GetLastRestorableSessionUseCase742+ snapshot *usecase.SnapshotSessionUseCase
741- checkUpdate *usecase.CheckUpdateUseCase743+ lastRestorable *usecase.GetLastRestorableSessionUseCase
742- applyUpdate *usecase.ApplyUpdateUseCase744+ checkUpdate *usecase.CheckUpdateUseCase
743- clipboard port.Clipboard745+ applyUpdate *usecase.ApplyUpdateUseCase
744- favicon *infrafavicon.Service746+ clipboard port.Clipboard
745- faviconUC *usecase.FaviconUseCase747+ 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()
746 }756 }
747 757
748 func createUseCases(repos *repositories, cfg *config.Config) *useCases {758 func createUseCases(repos *repositories, cfg *config.Config) *useCases {
@@ -783,23 +793,29 @@ func createUseCases(repos *repositories, cfg *config.Config) *useCases {
783 793
784 // Permission use case will be initialized later with dialog presenter794 // Permission use case will be initialized later with dialog presenter
785 permissionUC := usecase.NewHandlePermissionUseCase(repos.permission, nil, logging.FromContext)795 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)
786 801
787 return &useCases{802 return &useCases{
788- tabs: usecase.NewManageTabsUseCase(idGenerator),803+ tabs: usecase.NewManageTabsUseCase(idGenerator),
789- panes: usecase.NewManagePanesUseCase(idGenerator),804+ panes: usecase.NewManagePanesUseCase(idGenerator),
790- history: usecase.NewSearchHistoryUseCase(repos.history),805+ history: historyUC,
791- favorites: usecase.NewManageFavoritesUseCase(repos.favorite, repos.folder, repos.tag),806+ favorites: usecase.NewManageFavoritesUseCase(repos.favorite, repos.folder, repos.tag),
792- zoom: usecase.NewManageZoomUseCase(repos.zoom, defaultZoom, zoomCache),807+ zoom: usecase.NewManageZoomUseCase(repos.zoom, defaultZoom, zoomCache),
793- permission: permissionUC,808+ permission: permissionUC,
794- navigate: usecase.NewNavigateUseCase(repos.history, repos.zoom, defaultZoom),809+ navigate: usecase.NewNavigateUseCase(defaultZoom),
795- copyURL: usecase.NewCopyURLUseCase(clipboardAdapter),810+ historyRecorder: historyRecorderUC,
796- snapshot: usecase.NewSnapshotSessionUseCase(repos.sessionState),811+ copyURL: usecase.NewCopyURLUseCase(clipboardAdapter),
797- lastRestorable: usecase.NewGetLastRestorableSessionUseCase(repos.session, repos.sessionState),812+ snapshot: usecase.NewSnapshotSessionUseCase(repos.sessionState),
798- checkUpdate: usecase.NewCheckUpdateUseCase(updateChecker, updateApplier, buildInfo),813+ lastRestorable: usecase.NewGetLastRestorableSessionUseCase(repos.session, repos.sessionState),
799- applyUpdate: usecase.NewApplyUpdateUseCase(updateDownloader, updateApplier, xdgDirs.CacheHome),814+ checkUpdate: usecase.NewCheckUpdateUseCase(updateChecker, updateApplier, buildInfo),
800- clipboard: clipboardAdapter,815+ applyUpdate: usecase.NewApplyUpdateUseCase(updateDownloader, updateApplier, xdgDirs.CacheHome),
801- favicon: faviconService,816+ clipboard: clipboardAdapter,
802- faviconUC: faviconUC,817+ favicon: faviconService,
818+ faviconUC: faviconUC,
803 }819 }
804 }820 }
805 821
@@ -896,6 +912,7 @@ func buildUIDependencies(
896 ZoomUC: uc.zoom,912 ZoomUC: uc.zoom,
897 PermissionUC: uc.permission,913 PermissionUC: uc.permission,
898 NavigateUC: uc.navigate,914 NavigateUC: uc.navigate,
915+ HistoryRecorderUC: uc.historyRecorder,
899 CopyURLUC: uc.copyURL,916 CopyURLUC: uc.copyURL,
900 Clipboard: uc.clipboard,917 Clipboard: uc.clipboard,
901 FaviconService: legacyFaviconService(uc),918 FaviconService: legacyFaviconService(uc),
M cmd/systemviews/bootstrap_test.go +1 -1
@@ -157,7 +157,7 @@ func (f *bridgeServiceRecorder) TimelineByDomain(context.Context, string, int, i
157 return f.historyEntries, nil157 return f.historyEntries, nil
158 }158 }
159 159
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) {
161 f.calledHistory.Store(true)161 f.calledHistory.Store(true)
162 return &entity.HistoryWindow{Entries: f.historyEntries, Before: before, After: before.Add(-24 * time.Hour)}, nil162 return &entity.HistoryWindow{Entries: f.historyEntries, Before: before, After: before.Add(-24 * time.Hour)}, nil
163 }163 }
M go.mod +2 -2
@@ -16,13 +16,13 @@ require (
16 github.com/charmbracelet/lipgloss v1.1.016 github.com/charmbracelet/lipgloss v1.1.0
17 github.com/fsnotify/fsnotify v1.10.117 github.com/fsnotify/fsnotify v1.10.1
18 github.com/godbus/dbus/v5 v5.2.218 github.com/godbus/dbus/v5 v5.2.2
19- github.com/pelletier/go-toml/v2 v2.4.019+ github.com/pelletier/go-toml/v2 v2.4.2
20 github.com/pressly/goose/v3 v3.27.120 github.com/pressly/goose/v3 v3.27.1
21 github.com/rs/zerolog v1.35.121 github.com/rs/zerolog v1.35.1
22 github.com/spf13/cobra v1.10.222 github.com/spf13/cobra v1.10.2
23 github.com/spf13/viper v1.21.023 github.com/spf13/viper v1.21.0
24 github.com/stretchr/testify v1.11.124 github.com/stretchr/testify v1.11.1
25- golang.org/x/image v0.42.025+ golang.org/x/image v0.43.0
26 golang.org/x/net v0.56.026 golang.org/x/net v0.56.0
27 golang.org/x/sys v0.46.027 golang.org/x/sys v0.46.0
28 gopkg.in/yaml.v3 v3.0.128 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 (
12 type HomepageHistory interface {12 type HomepageHistory interface {
13 GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)13 GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
14 GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error)14 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)
16 Search(ctx context.Context, input dto.HistorySearchInput) (*dto.HistorySearchOutput, error)16 Search(ctx context.Context, input dto.HistorySearchInput) (*dto.HistorySearchOutput, error)
17 Delete(ctx context.Context, id int64) error17 Delete(ctx context.Context, id int64) error
18 ClearRange(ctx context.Context, rangeID string) error18 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
604 }604 }
605 605
606 // GetRecentWindow provides a mock function for the type MockHomepageHistory606 // 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) {607+func (_mock *MockHomepageHistory) GetRecentWindow(ctx context.Context, before time.Time, beforeID int64, domain string) (*entity.HistoryWindow, error) {
608- ret := _mock.Called(ctx, before, domain)608+ ret := _mock.Called(ctx, before, beforeID, domain)
609 609
610 if len(ret) == 0 {610 if len(ret) == 0 {
611 panic("no return value specified for GetRecentWindow")611 panic("no return value specified for GetRecentWindow")
@@ -613,18 +613,18 @@ func (_mock *MockHomepageHistory) GetRecentWindow(ctx context.Context, before ti
613 613
614 var r0 *entity.HistoryWindow614 var r0 *entity.HistoryWindow
615 var r1 error615 var r1 error
616- if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, string) (*entity.HistoryWindow, error)); ok {616+ if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, int64, string) (*entity.HistoryWindow, error)); ok {
617- return returnFunc(ctx, before, domain)617+ return returnFunc(ctx, before, beforeID, domain)
618 }618 }
619- if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, string) *entity.HistoryWindow); ok {619+ if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, int64, string) *entity.HistoryWindow); ok {
620- r0 = returnFunc(ctx, before, domain)620+ r0 = returnFunc(ctx, before, beforeID, domain)
621 } else {621 } else {
622 if ret.Get(0) != nil {622 if ret.Get(0) != nil {
623 r0 = ret.Get(0).(*entity.HistoryWindow)623 r0 = ret.Get(0).(*entity.HistoryWindow)
624 }624 }
625 }625 }
626- if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time, string) error); ok {626+ if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time, int64, string) error); ok {
627- r1 = returnFunc(ctx, before, domain)627+ r1 = returnFunc(ctx, before, beforeID, domain)
628 } else {628 } else {
629 r1 = ret.Error(1)629 r1 = ret.Error(1)
630 }630 }
@@ -639,12 +639,13 @@ type MockHomepageHistory_GetRecentWindow_Call struct {
639 // GetRecentWindow is a helper method to define mock.On call639 // GetRecentWindow is a helper method to define mock.On call
640 // - ctx context.Context640 // - ctx context.Context
641 // - before time.Time641 // - before time.Time
642+// - beforeID int64
642 // - domain string643 // - domain string
643-func (_e *MockHomepageHistory_Expecter) GetRecentWindow(ctx any, before any, domain any) *MockHomepageHistory_GetRecentWindow_Call {644+func (_e *MockHomepageHistory_Expecter) GetRecentWindow(ctx any, before any, beforeID any, domain any) *MockHomepageHistory_GetRecentWindow_Call {
644- return &MockHomepageHistory_GetRecentWindow_Call{Call: _e.mock.On("GetRecentWindow", ctx, before, domain)}645+ return &MockHomepageHistory_GetRecentWindow_Call{Call: _e.mock.On("GetRecentWindow", ctx, before, beforeID, domain)}
645 }646 }
646 647
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 {
648 _c.Call.Run(func(args mock.Arguments) {649 _c.Call.Run(func(args mock.Arguments) {
649 var arg0 context.Context650 var arg0 context.Context
650 if args[0] != nil {651 if args[0] != nil {
@@ -654,14 +655,19 @@ func (_c *MockHomepageHistory_GetRecentWindow_Call) Run(run func(ctx context.Con
654 if args[1] != nil {655 if args[1] != nil {
655 arg1 = args[1].(time.Time)656 arg1 = args[1].(time.Time)
656 }657 }
657- var arg2 string658+ var arg2 int64
658 if args[2] != nil {659 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)
660 }665 }
661 run(666 run(
662 arg0,667 arg0,
663 arg1,668 arg1,
664 arg2,669 arg2,
670+ arg3,
665 )671 )
666 })672 })
667 return _c673 return _c
@@ -672,7 +678,7 @@ func (_c *MockHomepageHistory_GetRecentWindow_Call) Return(historyWindow *entity
672 return _c678 return _c
673 }679 }
674 680
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 {
676 _c.Call.Return(run)682 _c.Call.Return(run)
677 return _c683 return _c
678 }684 }
M internal/application/port/systemviews.go +1 -1
@@ -12,7 +12,7 @@ import (
12 type SystemviewHistoryService interface {12 type SystemviewHistoryService interface {
13 Timeline(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)13 Timeline(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
14 TimelineByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error)14 TimelineByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error)
15- TimelineWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error)15+ TimelineWindow(ctx context.Context, before time.Time, beforeID int64, domain string) (*entity.HistoryWindow, error)
16 Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error)16 Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error)
17 DeleteEntry(ctx context.Context, id int64) error17 DeleteEntry(ctx context.Context, id int64) error
18 DeleteRange(ctx context.Context, rangeID string) error18 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
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6- "net/url"6+ "math"
7- "strings"
8- "sync"
9- "time"
10 7
11 "github.com/bnema/dumber/internal/application/port"8 "github.com/bnema/dumber/internal/application/port"
12 "github.com/bnema/dumber/internal/domain/entity"9 "github.com/bnema/dumber/internal/domain/entity"
13- "github.com/bnema/dumber/internal/domain/repository"
14 "github.com/bnema/dumber/internal/logging"10 "github.com/bnema/dumber/internal/logging"
15 )11 )
16 12
17-const (13+// NavigateUseCase handles URL navigation with zoom application.
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.
58 type NavigateUseCase struct {14 type NavigateUseCase struct {
59- historyRepo repository.HistoryRepository15+ defaultZoom float64
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
71 }16 }
72 17
73 // NewNavigateUseCase creates a new navigation use case.18 // NewNavigateUseCase creates a new navigation use case.
74 // defaultZoom is the zoom level to use when no per-domain zoom is saved (typically from config).19 // defaultZoom is the zoom level to use when no per-domain zoom is saved (typically from config).
75-func NewNavigateUseCase(20+func NewNavigateUseCase(defaultZoom float64) *NavigateUseCase {
76- historyRepo repository.HistoryRepository,21+ if defaultZoom <= 0 || math.IsNaN(defaultZoom) || math.IsInf(defaultZoom, 0) {
77- zoomRepo repository.ZoomRepository,
78- defaultZoom float64,
79-) *NavigateUseCase {
80- if defaultZoom <= 0 {
81 defaultZoom = entity.ZoomDefault22 defaultZoom = entity.ZoomDefault
82 }23 }
83 24
84- uc := &NavigateUseCase{25+ return &NavigateUseCase{defaultZoom: defaultZoom}
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()
108 }26 }
109 27
110 // NavigateInput contains parameters for navigation.28 // NavigateInput contains parameters for navigation.
@@ -119,7 +37,7 @@ type NavigateOutput struct {
119 AppliedZoom float6437 AppliedZoom float64
120 }38 }
121 39
122-// Execute navigates to a URL and records history.40+// Execute navigates to a URL.
123 // Zoom is applied later via LoadCommitted event in ContentCoordinator.41 // Zoom is applied later via LoadCommitted event in ContentCoordinator.
124 func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*NavigateOutput, error) {42 func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*NavigateOutput, error) {
125 log := logging.FromContext(ctx)43 log := logging.FromContext(ctx)
@@ -143,259 +61,6 @@ func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*N
143 }, nil61 }, nil
144 }62 }
145 63
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-
399 // Reload reloads the current page.64 // Reload reloads the current page.
400 func (uc *NavigateUseCase) Reload(ctx context.Context, webview port.WebView, bypassCache bool) error {65 func (uc *NavigateUseCase) Reload(ctx context.Context, webview port.WebView, bypassCache bool) error {
401 log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger()66 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
413 log.Debug().Msg("stopping page load")78 log.Debug().Msg("stopping page load")
414 return webview.Stop(ctx)79 return webview.Stop(ctx)
415 }80 }
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
2 2
3 import (3 import (
4 "context"4 "context"
5- "path/filepath"5+ "errors"
6- "sync"6+ "math"
7 "testing"7 "testing"
8- "time"
9 8
9+ "github.com/bnema/dumber/internal/application/port"
10 "github.com/bnema/dumber/internal/domain/entity"10 "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"
14 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
15 )12 )
16 13
17-func TestCanonicalizeURLForHistory_StripsHashTrackingAndTrailingSlash(t *testing.T) {14+type fakeWebView struct {
18- got := canonicalizeURLForHistory(15+ loaded string
19- "https://Example.com/path/?utm_source=newsletter&utm_campaign=launch&a=2&b=1#section-1",16+ loadErr error
20- historyCanonicalizationOptions{StripTrackingParams: true},
21- )
22-
23- require.Equal(t, "https://example.com/path?a=2&b=1", got)
24 }17 }
25 18
26-func TestCanonicalizeURLForHistory_OptionallyKeepsTrackingParams(t *testing.T) {19+func (f *fakeWebView) LoadURI(_ context.Context, uri string) error {
27- got := canonicalizeURLForHistory(20+ if f.loadErr != nil {
28- "https://example.com/path/?utm_source=newsletter&b=1#section-1",21+ return f.loadErr
29- historyCanonicalizationOptions{StripTrackingParams: false},22+ }
30- )23+ f.loaded = uri
31-24+ return nil
32- require.Equal(t, "https://example.com/path?b=1&utm_source=newsletter", got)
33 }25 }
34-26+func (*fakeWebView) LoadHTML(context.Context, string, string) error { return nil }
35-func TestRecordHistory_IgnoresHashOnlyTransitions(t *testing.T) {27+func (*fakeWebView) Reload(context.Context) error { return nil }
36- ctx := context.Background()28+func (*fakeWebView) ReloadBypassCache(context.Context) error { return nil }
37- repo := repomocks.NewMockHistoryRepository(t)29+func (*fakeWebView) Stop(context.Context) error { return nil }
38-30+func (*fakeWebView) GoBack(context.Context) error { return nil }
39- repo.EXPECT().FindByURL(mock.Anything, "https://example.com/docs").Return(nil, nil).Once()31+func (*fakeWebView) GoForward(context.Context) error { return nil }
40- repo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(entry *entity.HistoryEntry) bool {32+func (*fakeWebView) ID() port.WebViewID { return 1 }
41- return entry.URL == "https://example.com/docs" && entry.VisitCount == 133+func (*fakeWebView) State() port.WebViewState { return port.WebViewState{} }
42- })).Return(nil).Once()34+func (f *fakeWebView) URI() string { return f.loaded }
43-35+func (*fakeWebView) Title() string { return "" }
44- uc := NewNavigateUseCase(repo, nil, entity.ZoomDefault)36+func (*fakeWebView) IsLoading() bool { return false }
45- uc.RecordHistory(ctx, "pane-1", "https://example.com/docs#intro")37+func (*fakeWebView) EstimatedProgress() float64 { return 1 }
46- uc.RecordHistory(ctx, "pane-1", "https://example.com/docs#api")38+func (*fakeWebView) CanGoBack() bool { return false }
47- uc.Close()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) {
48 }46 }
49-47+func (*fakeWebView) ResetBackgroundToDefault() {}
50-func TestRecordHistory_DedupIsPerPaneAndCoalescesVisits(t *testing.T) {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) {
51 ctx := context.Background()56 ctx := context.Background()
52- repo := repomocks.NewMockHistoryRepository(t)57+ wv := &fakeWebView{}
53-58+ uc := NewNavigateUseCase(entity.ZoomDefault)
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()
58 59
59- uc := NewNavigateUseCase(repo, nil, entity.ZoomDefault)60+ out, err := uc.Execute(ctx, NavigateInput{URL: "https://example.com", PaneID: "pane-1", WebView: wv})
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-}
65 61
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)
70 require.NoError(t, err)62 require.NoError(t, err)
71- t.Cleanup(func() { _ = db.Close() })63+ require.Equal(t, "https://example.com", wv.loaded)
72-64+ require.InDelta(t, entity.ZoomDefault, out.AppliedZoom, 0.0001)
73- repo := sqlite.NewHistoryRepository(db)65+}
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()
86 66
87- after, err := repo.FindByURL(ctx, "https://example.com/article")67+func TestNavigateUseCase_ExecuteUsesConfiguredOrFallbackZoom(t *testing.T) {
88- require.NoError(t, err)68+ tests := []struct {
89- require.NotNil(t, after)69+ name string
90- require.Equal(t, "New", after.Title)70+ configuredZoom float64
91- require.Equal(t, before.VisitCount, after.VisitCount)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+ }
92 }93 }
93 94
94-func TestHistoryWorker_ReenqueueDuringFlushIsPersistedOnShutdown(t *testing.T) {95+func TestNavigateUseCase_ExecuteReturnsLoadError(t *testing.T) {
95 ctx := context.Background()96 ctx := context.Background()
97+ loadErr := errors.New("load failed")
98+ wv := &fakeWebView{loadErr: loadErr}
99+ uc := NewNavigateUseCase(entity.ZoomDefault)
96 100
97- // Stateful store — Save writes here, FindByURL reads from here.101+ out, err := uc.Execute(ctx, NavigateInput{URL: "https://example.com", PaneID: "pane-1", WebView: wv})
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()
150 102
151- mu.Lock()103+ require.Nil(t, out)
152- entry := store["https://example.com/article"]104+ require.ErrorIs(t, err, loadErr)
153- mu.Unlock()105+ require.Contains(t, err.Error(), "failed to load URL")
154- require.NotNil(t, entry)
155- require.Equal(t, int64(1), entry.VisitCount)
156- require.Equal(t, "Queued title", entry.Title)
157 }106 }
M internal/application/usecase/search_history.go +172 -18
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6 "strings"6 "strings"
7+ "sync"
7 "time"8 "time"
8 9
9 "github.com/bnema/dumber/internal/application/dto"10 "github.com/bnema/dumber/internal/application/dto"
@@ -22,6 +23,7 @@ var _ port.HistorySidebarHistory = (*SearchHistoryUseCase)(nil)
22 23
23 const (24 const (
24 historyWindowDuration = 24 * time.Hour25 historyWindowDuration = 24 * time.Hour
26+ historyWindowRowLimit = 100
25 27
26 defaultHistoryPageLimit = 5028 defaultHistoryPageLimit = 50
27 maxHistoryPageLimit = 50029 maxHistoryPageLimit = 500
@@ -33,14 +35,48 @@ const (
33 35
34 // SearchHistoryUseCase handles history search and retrieval operations.36 // SearchHistoryUseCase handles history search and retrieval operations.
35 type SearchHistoryUseCase struct {37 type SearchHistoryUseCase struct {
36- historyRepo repository.HistoryRepository38+ historyRepo repository.HistoryRepository
39+ changeSinkMu sync.RWMutex
40+ changeSink port.HistoryChangeSink
41+ mutationCoordinatorMu sync.RWMutex
42+ mutationCoordinator port.HistoryMutationCoordinator
37 }43 }
38 44
39 // NewSearchHistoryUseCase creates a new history search use case.45 // 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+
41 return &SearchHistoryUseCase{52 return &SearchHistoryUseCase{
42 historyRepo: historyRepo,53 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
43 }76 }
77+ uc.mutationCoordinatorMu.Lock()
78+ uc.mutationCoordinator = coordinator
79+ uc.mutationCoordinatorMu.Unlock()
44 }80 }
45 81
46 // SearchInput is an alias for dto.HistorySearchInput.82 // SearchInput is an alias for dto.HistorySearchInput.
@@ -115,17 +151,30 @@ func canonicalHistoryDomain(domain string) (string, error) {
115 return domain, nil151 return domain, nil
116 }152 }
117 153
118-// GetRecentWindow retrieves the 24-hour history window ending before the given cursor.154+// GetRecentWindow retrieves a row-bounded history window ending before the given cursor.
119-func (uc *SearchHistoryUseCase) GetRecentWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) {155+func (uc *SearchHistoryUseCase) GetRecentWindow(
120- if before.IsZero() {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 {
121 before = time.Now()170 before = time.Now()
122 }171 }
123 before = before.UTC()172 before = before.UTC()
124 after := before.Add(-historyWindowDuration)173 after := before.Add(-historyWindowDuration)
174+ queryLimit := historyWindowRowLimit + 1
125 175
126 var (176 var (
127 entries []*entity.HistoryEntry177 entries []*entity.HistoryEntry
128- hasMore bool
129 err error178 err error
130 )179 )
131 originalDomain := domain180 originalDomain := domain
@@ -134,25 +183,35 @@ func (uc *SearchHistoryUseCase) GetRecentWindow(ctx context.Context, before time
134 return nil, fmt.Errorf("domain is required")183 return nil, fmt.Errorf("domain is required")
135 }184 }
136 if domain != "" {185 if domain != "" {
137- entries, err = uc.historyRepo.GetRecentWindowByDomain(ctx, domain, before, after)186+ entries, err = uc.historyRepo.GetRecentWindowByDomain(ctx, domain, before, beforeID, queryLimit)
138- if err == nil {
139- hasMore, err = uc.historyRepo.HasEntriesByDomainBefore(ctx, domain, after)
140- }
141 } else {187 } else {
142- entries, err = uc.historyRepo.GetRecentWindow(ctx, before, after)188+ entries, err = uc.historyRepo.GetRecentWindow(ctx, before, beforeID, queryLimit)
143- if err == nil {
144- hasMore, err = uc.historyRepo.HasEntriesBefore(ctx, after)
145- }
146 }189 }
147 if err != nil {190 if err != nil {
148 return nil, fmt.Errorf("failed to get recent history window: %w", err)191 return nil, fmt.Errorf("failed to get recent history window: %w", err)
149 }192 }
150 193
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+
151 return &entity.HistoryWindow{208 return &entity.HistoryWindow{
152- Entries: entries,209+ Entries: entries,
153- Before: before,210+ Before: before,
154- After: after,211+ After: after,
155- HasMore: hasMore,212+ CursorLastVisited: cursorLastVisited,
213+ CursorID: cursorID,
214+ HasMore: hasMore,
156 }, nil215 }, nil
157 }216 }
158 217
@@ -228,10 +287,19 @@ func (uc *SearchHistoryUseCase) ClearOlderThan(ctx context.Context, before time.
228 log := logging.FromContext(ctx)287 log := logging.FromContext(ctx)
229 log.Debug().Time("before", before).Msg("clearing old history")288 log.Debug().Time("before", before).Msg("clearing old history")
230 289
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+
231 if err := uc.historyRepo.DeleteOlderThan(ctx, before); err != nil {297 if err := uc.historyRepo.DeleteOlderThan(ctx, before); err != nil {
232 return fmt.Errorf("failed to clear history: %w", err)298 return fmt.Errorf("failed to clear history: %w", err)
233 }299 }
300+ release()
234 301
302+ uc.publishHistoryChange(ctx, dto.HistoryChangeReasonClear, 0, false)
235 log.Info().Time("before", before).Msg("old history cleared")303 log.Info().Time("before", before).Msg("old history cleared")
236 return nil304 return nil
237 }305 }
@@ -248,9 +316,18 @@ func (uc *SearchHistoryUseCase) ClearRange(ctx context.Context, rangeID string)
248 316
249 log := logging.FromContext(ctx)317 log := logging.FromContext(ctx)
250 log.Debug().Time("since", cutoff).Str("range", rangeID).Msg("clearing recent history range")318 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+
251 if err := uc.historyRepo.DeleteSince(ctx, cutoff); err != nil {326 if err := uc.historyRepo.DeleteSince(ctx, cutoff); err != nil {
252 return fmt.Errorf("failed to clear history range: %w", err)327 return fmt.Errorf("failed to clear history range: %w", err)
253 }328 }
329+ release()
330+ uc.publishHistoryChange(ctx, dto.HistoryChangeReasonClear, 0, false)
254 log.Info().Time("since", cutoff).Str("range", rangeID).Msg("recent history range cleared")331 log.Info().Time("since", cutoff).Str("range", rangeID).Msg("recent history range cleared")
255 return nil332 return nil
256 }333 }
@@ -260,10 +337,19 @@ func (uc *SearchHistoryUseCase) ClearAll(ctx context.Context) error {
260 log := logging.FromContext(ctx)337 log := logging.FromContext(ctx)
261 log.Debug().Msg("clearing all history")338 log.Debug().Msg("clearing all history")
262 339
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+
263 if err := uc.historyRepo.DeleteAll(ctx); err != nil {347 if err := uc.historyRepo.DeleteAll(ctx); err != nil {
264 return fmt.Errorf("failed to clear all history: %w", err)348 return fmt.Errorf("failed to clear all history: %w", err)
265 }349 }
350+ release()
266 351
352+ uc.publishHistoryChange(ctx, dto.HistoryChangeReasonClear, 0, false)
267 log.Info().Msg("all history cleared")353 log.Info().Msg("all history cleared")
268 return nil354 return nil
269 }355 }
@@ -273,10 +359,19 @@ func (uc *SearchHistoryUseCase) Delete(ctx context.Context, id int64) error {
273 log := logging.FromContext(ctx)359 log := logging.FromContext(ctx)
274 log.Debug().Int64("id", id).Msg("deleting history entry")360 log.Debug().Int64("id", id).Msg("deleting history entry")
275 361
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+
276 if err := uc.historyRepo.Delete(ctx, id); err != nil {369 if err := uc.historyRepo.Delete(ctx, id); err != nil {
277 return fmt.Errorf("failed to delete history entry: %w", err)370 return fmt.Errorf("failed to delete history entry: %w", err)
278 }371 }
372+ release()
279 373
374+ uc.publishHistoryChange(ctx, dto.HistoryChangeReasonDelete, 1, true)
280 log.Debug().Int64("id", id).Msg("history entry deleted")375 log.Debug().Int64("id", id).Msg("history entry deleted")
281 return nil376 return nil
282 }377 }
@@ -291,14 +386,73 @@ func (uc *SearchHistoryUseCase) DeleteByDomain(ctx context.Context, domain strin
291 log := logging.FromContext(ctx)386 log := logging.FromContext(ctx)
292 log.Debug().Str("domain", domain).Msg("deleting history by domain")387 log.Debug().Str("domain", domain).Msg("deleting history by domain")
293 388
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+
294 if err := uc.historyRepo.DeleteByDomain(ctx, domain); err != nil {396 if err := uc.historyRepo.DeleteByDomain(ctx, domain); err != nil {
295 return fmt.Errorf("failed to delete history by domain: %w", err)397 return fmt.Errorf("failed to delete history by domain: %w", err)
296 }398 }
399+ release()
297 400
401+ uc.publishHistoryChange(ctx, dto.HistoryChangeReasonDelete, 0, false)
298 log.Info().Str("domain", domain).Msg("history deleted for domain")402 log.Info().Str("domain", domain).Msg("history deleted for domain")
299 return nil403 return nil
300 }404 }
301 405
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+
302 // GetDomainStats retrieves per-domain visit statistics.456 // GetDomainStats retrieves per-domain visit statistics.
303 func (uc *SearchHistoryUseCase) GetDomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error) {457 func (uc *SearchHistoryUseCase) GetDomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error) {
304 log := logging.FromContext(ctx)458 log := logging.FromContext(ctx)
M internal/application/usecase/search_history_test.go +511 -10
@@ -1,10 +1,12 @@
1 package usecase_test1 package usecase_test
2 2
3 import (3 import (
4+ "context"
4 "errors"5 "errors"
5 "testing"6 "testing"
6 "time"7 "time"
7 8
9+ "github.com/bnema/dumber/internal/application/dto"
8 "github.com/bnema/dumber/internal/application/usecase"10 "github.com/bnema/dumber/internal/application/usecase"
9 "github.com/bnema/dumber/internal/domain/entity"11 "github.com/bnema/dumber/internal/domain/entity"
10 repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"12 repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
@@ -13,6 +15,50 @@ import (
13 "github.com/stretchr/testify/require"15 "github.com/stretchr/testify/require"
14 )16 )
15 17
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+
16 func TestSearchHistoryUseCase_GetRecent_ZeroLimitMeansAll(t *testing.T) {62 func TestSearchHistoryUseCase_GetRecent_ZeroLimitMeansAll(t *testing.T) {
17 ctx := testContext()63 ctx := testContext()
18 historyRepo := repomocks.NewMockHistoryRepository(t)64 historyRepo := repomocks.NewMockHistoryRepository(t)
@@ -68,13 +114,56 @@ func TestSearchHistoryUseCase_GetRecentWindow_InvalidDomainReturnsValidationErro
68 historyRepo := repomocks.NewMockHistoryRepository(t)114 historyRepo := repomocks.NewMockHistoryRepository(t)
69 uc := usecase.NewSearchHistoryUseCase(historyRepo)115 uc := usecase.NewSearchHistoryUseCase(historyRepo)
70 116
71- result, err := uc.GetRecentWindow(ctx, time.Now(), "///")117+ result, err := uc.GetRecentWindow(ctx, time.Time{}, 0, "///")
72 118
73 require.Error(t, err)119 require.Error(t, err)
74 assert.Nil(t, result)120 assert.Nil(t, result)
75 assert.Contains(t, err.Error(), "domain is required")121 assert.Contains(t, err.Error(), "domain is required")
76 }122 }
77 123
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+
78 func TestSearchHistoryUseCase_GetRecentSince_ReturnsEntries(t *testing.T) {167 func TestSearchHistoryUseCase_GetRecentSince_ReturnsEntries(t *testing.T) {
79 ctx := testContext()168 ctx := testContext()
80 169
@@ -226,7 +315,388 @@ func TestSearchHistoryUseCase_GetDomainStats_ClampsOversizedLimit(t *testing.T)
226 assert.Empty(t, result)315 assert.Empty(t, result)
227 }316 }
228 317
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) {
230 ctx := testContext()700 ctx := testContext()
231 historyRepo := repomocks.NewMockHistoryRepository(t)701 historyRepo := repomocks.NewMockHistoryRepository(t)
232 now := time.Now()702 now := time.Now()
@@ -234,11 +704,26 @@ func TestSearchHistoryUseCase_ClearRange_RecentRangeDeletesSinceCutoff(t *testin
234 historyRepo.EXPECT().DeleteSince(mock.Anything, mock.MatchedBy(func(since time.Time) bool {704 historyRepo.EXPECT().DeleteSince(mock.Anything, mock.MatchedBy(func(since time.Time) bool {
235 return since.After(expectedCutoff.Add(-time.Minute)) && since.Before(expectedCutoff.Add(time.Minute))705 return since.After(expectedCutoff.Add(-time.Minute)) && since.Before(expectedCutoff.Add(time.Minute))
236 })).Return(nil).Once()706 })).Return(nil).Once()
237-707+ sink := &recordingHistoryChangeSink{}
238- uc := usecase.NewSearchHistoryUseCase(historyRepo)708+ uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
239 709
240 err := uc.ClearRange(ctx, "hour")710 err := uc.ClearRange(ctx, "hour")
711+
241 require.NoError(t, err)712 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)
242 }727 }
243 728
244 func TestSearchHistoryUseCase_ClearRange_TodayDeletesSinceLocalMidnight(t *testing.T) {729 func TestSearchHistoryUseCase_ClearRange_TodayDeletesSinceLocalMidnight(t *testing.T) {
@@ -256,29 +741,45 @@ func TestSearchHistoryUseCase_ClearRange_TodayDeletesSinceLocalMidnight(t *testi
256 require.NoError(t, err)741 require.NoError(t, err)
257 }742 }
258 743
259-func TestSearchHistoryUseCase_ClearRange_AllUsesClearAll(t *testing.T) {744+func TestSearchHistoryUseCase_ClearRange_AllUsesClearAllAndPublishesOnce(t *testing.T) {
260 ctx := testContext()745 ctx := testContext()
261 746
262 historyRepo := repomocks.NewMockHistoryRepository(t)747 historyRepo := repomocks.NewMockHistoryRepository(t)
263 748
264- historyRepo.EXPECT().DeleteAll(mock.Anything).Return(nil)749+ historyRepo.EXPECT().DeleteAll(mock.Anything).Return(nil).Once()
265-750+ sink := &recordingHistoryChangeSink{}
266- uc := usecase.NewSearchHistoryUseCase(historyRepo)751+ uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
267 752
268 err := uc.ClearRange(ctx, "all")753 err := uc.ClearRange(ctx, "all")
269 require.NoError(t, err)754 require.NoError(t, err)
755+ requirePublishedHistoryChange(t, sink, dto.HistoryChangeReasonClear)
270 }756 }
271 757
272-func TestSearchHistoryUseCase_ClearRange_UnknownRangeDoesNotDelete(t *testing.T) {758+func TestSearchHistoryUseCase_ClearRange_AllDoesNotPublishOnRepositoryError(t *testing.T) {
273 ctx := testContext()759 ctx := testContext()
274 760
275 historyRepo := repomocks.NewMockHistoryRepository(t)761 historyRepo := repomocks.NewMockHistoryRepository(t)
276 762
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)
278 778
279 err := uc.ClearRange(ctx, "bogus")779 err := uc.ClearRange(ctx, "bogus")
280 require.Error(t, err)780 require.Error(t, err)
281 assert.Contains(t, err.Error(), "unknown history delete range")781 assert.Contains(t, err.Error(), "unknown history delete range")
782+ assert.Empty(t, sink.changes)
282 }783 }
283 784
284 func TestSearchHistoryUseCase_GetRecentByDomainAllowsUnderscoreDomains(t *testing.T) {785 func TestSearchHistoryUseCase_GetRecentByDomainAllowsUnderscoreDomains(t *testing.T) {
M internal/domain/entity/history_window.go +7 -5
@@ -2,10 +2,12 @@ package entity
2 2
3 import "time"3 import "time"
4 4
5-// HistoryWindow contains one fixed-duration slice of browsing history.5+// HistoryWindow contains one bounded slice of browsing history plus display window metadata.
6 type HistoryWindow struct {6 type HistoryWindow struct {
7- Entries []*HistoryEntry `json:"entries"`7+ Entries []*HistoryEntry `json:"entries"`
8- Before time.Time `json:"before"`8+ Before time.Time `json:"before"`
9- After time.Time `json:"after"`9+ After time.Time `json:"after"`
10- HasMore bool `json:"has_more"`10+ CursorLastVisited time.Time `json:"cursor_last_visited"`
11+ CursorID int64 `json:"cursor_id,omitempty"`
12+ HasMore bool `json:"has_more"`
11 }13 }
M internal/domain/repository/history.go +4 -10
@@ -24,17 +24,11 @@ type HistoryRepository interface {
24 // GetRecentByDomain retrieves recent history entries for a domain with pagination. A zero limit means all matching entries.24 // GetRecentByDomain retrieves recent history entries for a domain with pagination. A zero limit means all matching entries.
25 GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error)25 GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error)
26 26
27- // GetRecentWindow retrieves entries visited in [after, before), sorted by recency.27+ // GetRecentWindow retrieves entries older than the stable (last_visited, id) cursor, sorted by recency.
28- GetRecentWindow(ctx context.Context, before, after time.Time) ([]*entity.HistoryEntry, error)28+ GetRecentWindow(ctx context.Context, before time.Time, beforeID int64, limit int) ([]*entity.HistoryEntry, error)
29 29
30- // GetRecentWindowByDomain retrieves domain entries visited in [after, before), sorted by recency.30+ // GetRecentWindowByDomain retrieves domain entries older than the stable (last_visited, id) cursor, sorted by recency.
31- GetRecentWindowByDomain(ctx context.Context, domain string, before, after time.Time) ([]*entity.HistoryEntry, error)31+ GetRecentWindowByDomain(ctx context.Context, domain string, before time.Time, beforeID int64, limit int) ([]*entity.HistoryEntry, error)
32-
33- // HasEntriesBefore reports whether any history entry was visited before the given time.
34- HasEntriesBefore(ctx context.Context, before time.Time) (bool, error)
35-
36- // HasEntriesByDomainBefore reports whether any domain entry was visited before the given time.
37- HasEntriesByDomainBefore(ctx context.Context, domain string, before time.Time) (bool, error)
38 32
39 // GetRecentSince retrieves history entries visited within the last N days.33 // GetRecentSince retrieves history entries visited within the last N days.
40 // days must be > 0.34 // 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
999 }999 }
1000 1000
1001 // GetRecentWindow provides a mock function for the type MockHistoryRepository1001 // 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) {1002+func (_mock *MockHistoryRepository) GetRecentWindow(ctx context.Context, before time.Time, beforeID int64, limit int) ([]*entity.HistoryEntry, error) {
1003- ret := _mock.Called(ctx, before, after)1003+ ret := _mock.Called(ctx, before, beforeID, limit)
1004 1004
1005 if len(ret) == 0 {1005 if len(ret) == 0 {
1006 panic("no return value specified for GetRecentWindow")1006 panic("no return value specified for GetRecentWindow")
@@ -1008,18 +1008,18 @@ func (_mock *MockHistoryRepository) GetRecentWindow(ctx context.Context, before
1008 1008
1009 var r0 []*entity.HistoryEntry1009 var r0 []*entity.HistoryEntry
1010 var r1 error1010 var r1 error
1011- if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, time.Time) ([]*entity.HistoryEntry, error)); ok {1011+ if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, int64, int) ([]*entity.HistoryEntry, error)); ok {
1012- return returnFunc(ctx, before, after)1012+ return returnFunc(ctx, before, beforeID, limit)
1013 }1013 }
1014- if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, time.Time) []*entity.HistoryEntry); ok {1014+ if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, int64, int) []*entity.HistoryEntry); ok {
1015- r0 = returnFunc(ctx, before, after)1015+ r0 = returnFunc(ctx, before, beforeID, limit)
1016 } else {1016 } else {
1017 if ret.Get(0) != nil {1017 if ret.Get(0) != nil {
1018 r0 = ret.Get(0).([]*entity.HistoryEntry)1018 r0 = ret.Get(0).([]*entity.HistoryEntry)
1019 }1019 }
1020 }1020 }
1021- if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time, time.Time) error); ok {1021+ if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time, int64, int) error); ok {
1022- r1 = returnFunc(ctx, before, after)1022+ r1 = returnFunc(ctx, before, beforeID, limit)
1023 } else {1023 } else {
1024 r1 = ret.Error(1)1024 r1 = ret.Error(1)
1025 }1025 }
@@ -1034,12 +1034,13 @@ type MockHistoryRepository_GetRecentWindow_Call struct {
1034 // GetRecentWindow is a helper method to define mock.On call1034 // GetRecentWindow is a helper method to define mock.On call
1035 // - ctx context.Context1035 // - ctx context.Context
1036 // - before time.Time1036 // - before time.Time
1037-// - after time.Time1037+// - beforeID int64
1038-func (_e *MockHistoryRepository_Expecter) GetRecentWindow(ctx any, before any, after any) *MockHistoryRepository_GetRecentWindow_Call {1038+// - limit int
1039- return &MockHistoryRepository_GetRecentWindow_Call{Call: _e.mock.On("GetRecentWindow", ctx, before, after)}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)}
1040 }1041 }
1041 1042
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 {
1043 _c.Call.Run(func(args mock.Arguments) {1044 _c.Call.Run(func(args mock.Arguments) {
1044 var arg0 context.Context1045 var arg0 context.Context
1045 if args[0] != nil {1046 if args[0] != nil {
@@ -1049,14 +1050,19 @@ func (_c *MockHistoryRepository_GetRecentWindow_Call) Run(run func(ctx context.C
1049 if args[1] != nil {1050 if args[1] != nil {
1050 arg1 = args[1].(time.Time)1051 arg1 = args[1].(time.Time)
1051 }1052 }
1052- var arg2 time.Time1053+ var arg2 int64
1053 if args[2] != nil {1054 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)
1055 }1060 }
1056 run(1061 run(
1057 arg0,1062 arg0,
1058 arg1,1063 arg1,
1059 arg2,1064 arg2,
1065+ arg3,
1060 )1066 )
1061 })1067 })
1062 return _c1068 return _c
@@ -1067,14 +1073,14 @@ func (_c *MockHistoryRepository_GetRecentWindow_Call) Return(historyEntrys []*en
1067 return _c1073 return _c
1068 }1074 }
1069 1075
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 {
1071 _c.Call.Return(run)1077 _c.Call.Return(run)
1072 return _c1078 return _c
1073 }1079 }
1074 1080
1075 // GetRecentWindowByDomain provides a mock function for the type MockHistoryRepository1081 // 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) {1082+func (_mock *MockHistoryRepository) GetRecentWindowByDomain(ctx context.Context, domain string, before time.Time, beforeID int64, limit int) ([]*entity.HistoryEntry, error) {
1077- ret := _mock.Called(ctx, domain, before, after)1083+ ret := _mock.Called(ctx, domain, before, beforeID, limit)
1078 1084
1079 if len(ret) == 0 {1085 if len(ret) == 0 {
1080 panic("no return value specified for GetRecentWindowByDomain")1086 panic("no return value specified for GetRecentWindowByDomain")
@@ -1082,18 +1088,18 @@ func (_mock *MockHistoryRepository) GetRecentWindowByDomain(ctx context.Context,
1082 1088
1083 var r0 []*entity.HistoryEntry1089 var r0 []*entity.HistoryEntry
1084 var r1 error1090 var r1 error
1085- if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time, time.Time) ([]*entity.HistoryEntry, error)); ok {1091+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time, int64, int) ([]*entity.HistoryEntry, error)); ok {
1086- return returnFunc(ctx, domain, before, after)1092+ return returnFunc(ctx, domain, before, beforeID, limit)
1087 }1093 }
1088- if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time, time.Time) []*entity.HistoryEntry); ok {1094+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time, int64, int) []*entity.HistoryEntry); ok {
1089- r0 = returnFunc(ctx, domain, before, after)1095+ r0 = returnFunc(ctx, domain, before, beforeID, limit)
1090 } else {1096 } else {
1091 if ret.Get(0) != nil {1097 if ret.Get(0) != nil {
1092 r0 = ret.Get(0).([]*entity.HistoryEntry)1098 r0 = ret.Get(0).([]*entity.HistoryEntry)
1093 }1099 }
1094 }1100 }
1095- if returnFunc, ok := ret.Get(1).(func(context.Context, string, time.Time, time.Time) error); ok {1101+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, time.Time, int64, int) error); ok {
1096- r1 = returnFunc(ctx, domain, before, after)1102+ r1 = returnFunc(ctx, domain, before, beforeID, limit)
1097 } else {1103 } else {
1098 r1 = ret.Error(1)1104 r1 = ret.Error(1)
1099 }1105 }
@@ -1109,12 +1115,13 @@ type MockHistoryRepository_GetRecentWindowByDomain_Call struct {
1109 // - ctx context.Context1115 // - ctx context.Context
1110 // - domain string1116 // - domain string
1111 // - before time.Time1117 // - before time.Time
1112-// - after time.Time1118+// - beforeID int64
1113-func (_e *MockHistoryRepository_Expecter) GetRecentWindowByDomain(ctx any, domain any, before any, after any) *MockHistoryRepository_GetRecentWindowByDomain_Call {1119+// - limit int
1114- return &MockHistoryRepository_GetRecentWindowByDomain_Call{Call: _e.mock.On("GetRecentWindowByDomain", ctx, domain, before, after)}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)}
1115 }1122 }
1116 1123
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 {
1118 _c.Call.Run(func(args mock.Arguments) {1125 _c.Call.Run(func(args mock.Arguments) {
1119 var arg0 context.Context1126 var arg0 context.Context
1120 if args[0] != nil {1127 if args[0] != nil {
@@ -1128,15 +1135,20 @@ func (_c *MockHistoryRepository_GetRecentWindowByDomain_Call) Run(run func(ctx c
1128 if args[2] != nil {1135 if args[2] != nil {
1129 arg2 = args[2].(time.Time)1136 arg2 = args[2].(time.Time)
1130 }1137 }
1131- var arg3 time.Time1138+ var arg3 int64
1132 if args[3] != nil {1139 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)
1134 }1145 }
1135 run(1146 run(
1136 arg0,1147 arg0,
1137 arg1,1148 arg1,
1138 arg2,1149 arg2,
1139 arg3,1150 arg3,
1151+ arg4,
1140 )1152 )
1141 })1153 })
1142 return _c1154 return _c
@@ -1147,7 +1159,7 @@ func (_c *MockHistoryRepository_GetRecentWindowByDomain_Call) Return(historyEntr
1147 return _c1159 return _c
1148 }1160 }
1149 1161
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 {
1151 _c.Call.Return(run)1163 _c.Call.Return(run)
1152 return _c1164 return _c
1153 }1165 }
@@ -1214,144 +1226,6 @@ func (_c *MockHistoryRepository_GetStats_Call) RunAndReturn(run func(ctx context
1214 return _c1226 return _c
1215 }1227 }
1216 1228
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-
1355 // IncrementVisitCount provides a mock function for the type MockHistoryRepository1229 // IncrementVisitCount provides a mock function for the type MockHistoryRepository
1356 func (_mock *MockHistoryRepository) IncrementVisitCount(ctx context.Context, url string) error {1230 func (_mock *MockHistoryRepository) IncrementVisitCount(ctx context.Context, url string) error {
1357 ret := _mock.Called(ctx, url)1231 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
542 // CEF's OnLoadingProgressChange also provides progress=1.0 at completion.542 // CEF's OnLoadingProgressChange also provides progress=1.0 at completion.
543 }543 }
544 544
545-// OnLoadError is a no-op.545+const cefErrAborted purecef.Errorcode = -3
546-func (h *handlerSet) OnLoadError(_ purecef.Browser, _ purecef.Frame, _ purecef.Errorcode, _, _ string) {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+ }
547 }588 }
548 589
549 func mapCEFWindowDisposition(disposition purecef.WindowOpenDisposition) dto.WindowDisposition {590 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) {
423 assert.Empty(t, gotProgress)423 assert.Empty(t, gotProgress)
424 }424 }
425 425
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+
426 func TestOnTextSelectionChanged_ForwardsSelectionToClipboardOrchestrator(t *testing.T) {511 func TestOnTextSelectionChanged_ForwardsSelectionToClipboardOrchestrator(t *testing.T) {
427 recorder := &clipboardOrchestratorRecorder{}512 recorder := &clipboardOrchestratorRecorder{}
428 orchestrator := portmocks.NewMockClipboardTextOrchestrator(t)513 orchestrator := portmocks.NewMockClipboardTextOrchestrator(t)
M internal/infrastructure/handlers/homepage/history.go +11 -2
@@ -40,6 +40,7 @@ type timelineByDomainRequest struct {
40 type timelineWindowRequest struct {40 type timelineWindowRequest struct {
41 RequestID string `json:"requestId"`41 RequestID string `json:"requestId"`
42 Before string `json:"before"`42 Before string `json:"before"`
43+ BeforeID int64 `json:"beforeId"`
43 Domain string `json:"domain"`44 Domain string `json:"domain"`
44 }45 }
45 46
@@ -124,7 +125,15 @@ func (h *HistoryHandlers) HandleTimelineWindow() port.WebUIMessageHandler {
124 domain := strings.TrimSpace(req.Domain)125 domain := strings.TrimSpace(req.Domain)
125 126
126 var before time.Time127 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 {
128 parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(req.Before))137 parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(req.Before))
129 if err != nil {138 if err != nil {
130 return NewErrorResponse(req.RequestID, fmt.Errorf("invalid history window cursor")), nil139 return NewErrorResponse(req.RequestID, fmt.Errorf("invalid history window cursor")), nil
@@ -138,7 +147,7 @@ func (h *HistoryHandlers) HandleTimelineWindow() port.WebUIMessageHandler {
138 Time("before", before).147 Time("before", before).
139 Msg("handling history_timeline_window")148 Msg("handling history_timeline_window")
140 149
141- window, err := h.historyUC.GetRecentWindow(ctx, before, domain)150+ window, err := h.historyUC.GetRecentWindow(ctx, before, req.BeforeID, domain)
142 if err != nil {151 if err != nil {
143 return NewErrorResponse(req.RequestID, err), nil152 return NewErrorResponse(req.RequestID, err), nil
144 }153 }
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
524 return historyEntriesFromRows(rows), nil524 return historyEntriesFromRows(rows), nil
525 }525 }
526 526
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+ }
528 rows, err := r.queries.GetRecentHistoryWindow(ctx, sqlc.GetRecentHistoryWindowParams{531 rows, err := r.queries.GetRecentHistoryWindow(ctx, sqlc.GetRecentHistoryWindowParams{
529- Before: sql.NullTime{Time: before, Valid: true},532+ Before: sql.NullTime{Time: before, Valid: true},
530- After: sql.NullTime{Time: after, Valid: true},533+ BeforeID: beforeID,
534+ Limit: int64(limit),
531 })535 })
532 if err != nil {536 if err != nil {
533 return nil, err537 return nil, err
@@ -535,16 +539,26 @@ func (r *historyRepo) GetRecentWindow(ctx context.Context, before, after time.Ti
535 return historyEntriesFromRows(rows), nil539 return historyEntriesFromRows(rows), nil
536 }540 }
537 541
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) {
539 rawDomain := domain549 rawDomain := domain
540 domain = domainurl.CanonicalDomain(domain)550 domain = domainurl.CanonicalDomain(domain)
541 if domain == "" {551 if domain == "" {
542 return nil, fmt.Errorf("history domain is required: %q", rawDomain)552 return nil, fmt.Errorf("history domain is required: %q", rawDomain)
543 }553 }
554+ if limit <= 0 {
555+ return []*entity.HistoryEntry{}, nil
556+ }
544 rows, err := r.queries.GetRecentHistoryWindowByDomain(ctx, sqlc.GetRecentHistoryWindowByDomainParams{557 rows, err := r.queries.GetRecentHistoryWindowByDomain(ctx, sqlc.GetRecentHistoryWindowByDomainParams{
545- Domain: sql.NullString{String: domain, Valid: true},558+ Domain: sql.NullString{String: domain, Valid: true},
546- Before: sql.NullTime{Time: before, Valid: true},559+ Before: sql.NullTime{Time: before, Valid: true},
547- After: sql.NullTime{Time: after, Valid: true},560+ BeforeID: beforeID,
561+ Limit: int64(limit),
548 })562 })
549 if err != nil {563 if err != nil {
550 return nil, err564 return nil, err
@@ -552,30 +566,6 @@ func (r *historyRepo) GetRecentWindowByDomain(ctx context.Context, domain string
552 return historyEntriesFromRows(rows), nil566 return historyEntriesFromRows(rows), nil
553 }567 }
554 568
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-
579 func (r *historyRepo) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {569 func (r *historyRepo) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
580 if days <= 0 {570 if days <= 0 {
581 return nil, fmt.Errorf("days must be positive, got %d", days)571 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
2 2
3 import (3 import (
4 "context"4 "context"
5+ "fmt"
5 "path/filepath"6 "path/filepath"
6 "testing"7 "testing"
7 "time"8 "time"
@@ -890,3 +891,49 @@ func TestHistoryRepository_DomainColumnPowersFilteringStatsAndDelete(t *testing.
890 require.Len(t, remaining, 1)891 require.Len(t, remaining, 1)
891 assert.Equal(t, "https://other.test", remaining[0].URL)892 assert.Equal(t, "https://other.test", remaining[0].URL)
892 }893 }
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
156 return r.repo.GetRecentByDomain(ctx, domain, limit, offset)156 return r.repo.GetRecentByDomain(ctx, domain, limit, offset)
157 }157 }
158 158
159-func (r *LazyHistoryRepository) GetRecentWindow(ctx context.Context, before, after time.Time) ([]*entity.HistoryEntry, error) {159+func (r *LazyHistoryRepository) GetRecentWindow(
160+ ctx context.Context,
161+ before time.Time,
162+ beforeID int64,
163+ limit int,
164+) ([]*entity.HistoryEntry, error) {
160 if err := r.init(ctx); err != nil {165 if err := r.init(ctx); err != nil {
161 return nil, err166 return nil, err
162 }167 }
163- return r.repo.GetRecentWindow(ctx, before, after)168+ return r.repo.GetRecentWindow(ctx, before, beforeID, limit)
164 }169 }
165 170
166 func (r *LazyHistoryRepository) GetRecentWindowByDomain(171 func (r *LazyHistoryRepository) GetRecentWindowByDomain(
167 ctx context.Context,172 ctx context.Context,
168 domain string,173 domain string,
169 before time.Time,174 before time.Time,
170- after time.Time,175+ beforeID int64,
176+ limit int,
171 ) ([]*entity.HistoryEntry, error) {177 ) ([]*entity.HistoryEntry, error) {
172 if err := r.init(ctx); err != nil {178 if err := r.init(ctx); err != nil {
173 return nil, err179 return nil, err
174 }180 }
175- return r.repo.GetRecentWindowByDomain(ctx, domain, before, after)181+ return r.repo.GetRecentWindowByDomain(ctx, domain, before, beforeID, limit)
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)
190 }182 }
191 183
192 func (r *LazyHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {184 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 @@
1 -- name: GetRecentHistory :many1 -- 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 ?;
3 5
4 -- name: GetRecentHistoryByDomain :many6 -- name: GetRecentHistoryByDomain :many
5 SELECT * FROM history7 SELECT * FROM history
6 WHERE domain = @domain8 WHERE domain = @domain
7-ORDER BY last_visited DESC9+ORDER BY last_visited DESC, id DESC
8 LIMIT @limit OFFSET @offset;10 LIMIT @limit OFFSET @offset;
9 11
10 -- name: GetAllRecentHistoryByDomain :many12 -- name: GetAllRecentHistoryByDomain :many
11 SELECT * FROM history13 SELECT * FROM history
12 WHERE domain = @domain14 WHERE domain = @domain
13-ORDER BY last_visited DESC;15+ORDER BY last_visited DESC, id DESC;
14 16
15 -- name: GetRecentHistoryWindow :many17 -- name: GetRecentHistoryWindow :many
16 SELECT * FROM history18 SELECT * FROM history
17-WHERE last_visited < @before AND last_visited >= @after19+WHERE last_visited < @before OR (last_visited = @before AND id < @before_id)
18-ORDER BY last_visited DESC;20+ORDER BY last_visited DESC, id DESC
21+LIMIT @limit;
19 22
20 -- name: GetRecentHistoryWindowByDomain :many23 -- name: GetRecentHistoryWindowByDomain :many
21 SELECT * FROM history24 SELECT * FROM history
22-WHERE domain = @domain AND last_visited < @before AND last_visited >= @after25+WHERE domain = @domain AND (last_visited < @before OR (last_visited = @before AND id < @before_id))
23-ORDER BY last_visited DESC;26+ORDER BY last_visited DESC, id DESC
24-27+LIMIT @limit;
25--- name: HasHistoryBefore :one
26-SELECT EXISTS(SELECT 1 FROM history WHERE last_visited < @before LIMIT 1);
27-
28--- name: HasHistoryByDomainBefore :one
29-SELECT EXISTS(SELECT 1 FROM history WHERE domain = @domain AND last_visited < @before LIMIT 1);
30 28
31 -- name: GetHistoryByURL :one29 -- name: GetHistoryByURL :one
32 SELECT * FROM history WHERE url = ? LIMIT 1;30 SELECT * FROM history WHERE url = ? LIMIT 1;
@@ -153,7 +151,7 @@ ORDER BY visit_count DESC, last_visited DESC;
153 151
154 -- name: GetAllRecentHistory :many152 -- name: GetAllRecentHistory :many
155 SELECT * FROM history153 SELECT * FROM history
156-ORDER BY last_visited DESC;154+ORDER BY last_visited DESC, id DESC;
157 155
158 -- name: GetAllMostVisited :many156 -- name: GetAllMostVisited :many
159 SELECT * FROM history157 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) {
109 109
110 const GetAllRecentHistory = `-- name: GetAllRecentHistory :many110 const GetAllRecentHistory = `-- name: GetAllRecentHistory :many
111 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history111 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
112-ORDER BY last_visited DESC112+ORDER BY last_visited DESC, id DESC
113 `113 `
114 114
115 func (q *Queries) GetAllRecentHistory(ctx context.Context) ([]History, error) {115 func (q *Queries) GetAllRecentHistory(ctx context.Context) ([]History, error) {
@@ -147,7 +147,7 @@ func (q *Queries) GetAllRecentHistory(ctx context.Context) ([]History, error) {
147 const GetAllRecentHistoryByDomain = `-- name: GetAllRecentHistoryByDomain :many147 const GetAllRecentHistoryByDomain = `-- name: GetAllRecentHistoryByDomain :many
148 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history148 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
149 WHERE domain = ?1149 WHERE domain = ?1
150-ORDER BY last_visited DESC150+ORDER BY last_visited DESC, id DESC
151 `151 `
152 152
153 func (q *Queries) GetAllRecentHistoryByDomain(ctx context.Context, domain sql.NullString) ([]History, error) {153 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
387 }387 }
388 388
389 const GetRecentHistory = `-- name: GetRecentHistory :many389 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 ?
391 `393 `
392 394
393 type GetRecentHistoryParams struct {395 type GetRecentHistoryParams struct {
@@ -430,7 +432,7 @@ func (q *Queries) GetRecentHistory(ctx context.Context, arg GetRecentHistoryPara
430 const GetRecentHistoryByDomain = `-- name: GetRecentHistoryByDomain :many432 const GetRecentHistoryByDomain = `-- name: GetRecentHistoryByDomain :many
431 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history433 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
432 WHERE domain = ?1434 WHERE domain = ?1
433-ORDER BY last_visited DESC435+ORDER BY last_visited DESC, id DESC
434 LIMIT ?3 OFFSET ?2436 LIMIT ?3 OFFSET ?2
435 `437 `
436 438
@@ -512,17 +514,19 @@ func (q *Queries) GetRecentHistorySince(ctx context.Context, datetime interface{
512 514
513 const GetRecentHistoryWindow = `-- name: GetRecentHistoryWindow :many515 const GetRecentHistoryWindow = `-- name: GetRecentHistoryWindow :many
514 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history516 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
515-WHERE last_visited < ?1 AND last_visited >= ?2517+WHERE last_visited < ?1 OR (last_visited = ?1 AND id < ?2)
516-ORDER BY last_visited DESC518+ORDER BY last_visited DESC, id DESC
519+LIMIT ?3
517 `520 `
518 521
519 type GetRecentHistoryWindowParams struct {522 type GetRecentHistoryWindowParams struct {
520- Before sql.NullTime `json:"before"`523+ Before sql.NullTime `json:"before"`
521- After sql.NullTime `json:"after"`524+ BeforeID int64 `json:"before_id"`
525+ Limit int64 `json:"limit"`
522 }526 }
523 527
524 func (q *Queries) GetRecentHistoryWindow(ctx context.Context, arg GetRecentHistoryWindowParams) ([]History, error) {528 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)
526 if err != nil {530 if err != nil {
527 return nil, err531 return nil, err
528 }532 }
@@ -555,18 +559,25 @@ func (q *Queries) GetRecentHistoryWindow(ctx context.Context, arg GetRecentHisto
555 559
556 const GetRecentHistoryWindowByDomain = `-- name: GetRecentHistoryWindowByDomain :many560 const GetRecentHistoryWindowByDomain = `-- name: GetRecentHistoryWindowByDomain :many
557 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history561 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 >= ?3562+WHERE domain = ?1 AND (last_visited < ?2 OR (last_visited = ?2 AND id < ?3))
559-ORDER BY last_visited DESC563+ORDER BY last_visited DESC, id DESC
564+LIMIT ?4
560 `565 `
561 566
562 type GetRecentHistoryWindowByDomainParams struct {567 type GetRecentHistoryWindowByDomainParams struct {
563- Domain sql.NullString `json:"domain"`568+ Domain sql.NullString `json:"domain"`
564- Before sql.NullTime `json:"before"`569+ Before sql.NullTime `json:"before"`
565- After sql.NullTime `json:"after"`570+ BeforeID int64 `json:"before_id"`
571+ Limit int64 `json:"limit"`
566 }572 }
567 573
568 func (q *Queries) GetRecentHistoryWindowByDomain(ctx context.Context, arg GetRecentHistoryWindowByDomainParams) ([]History, error) {574 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+ )
570 if err != nil {581 if err != nil {
571 return nil, err582 return nil, err
572 }583 }
@@ -597,33 +608,6 @@ func (q *Queries) GetRecentHistoryWindowByDomain(ctx context.Context, arg GetRec
597 return items, nil608 return items, nil
598 }609 }
599 610
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-
627 const IncrementVisitCount = `-- name: IncrementVisitCount :exec611 const IncrementVisitCount = `-- name: IncrementVisitCount :exec
628 UPDATE history612 UPDATE history
629 SET visit_count = visit_count + 1, last_visited = CURRENT_TIMESTAMP613 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 {
72 GetTagsForFavorite(ctx context.Context, favoriteID int64) ([]FavoriteTag, error)72 GetTagsForFavorite(ctx context.Context, favoriteID int64) ([]FavoriteTag, error)
73 GetTotalSessionStatesSize(ctx context.Context) (interface{}, error)73 GetTotalSessionStatesSize(ctx context.Context) (interface{}, error)
74 GetZoomLevel(ctx context.Context, domain string) (ZoomLevel, error)74 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)
77 IncrementVisitCount(ctx context.Context, url string) error75 IncrementVisitCount(ctx context.Context, url string) error
78 IncrementVisitCountByDelta(ctx context.Context, arg IncrementVisitCountByDeltaParams) error76 IncrementVisitCountByDelta(ctx context.Context, arg IncrementVisitCountByDeltaParams) error
79 InsertSession(ctx context.Context, arg InsertSessionParams) error77 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
108 }{RequestID: nextRequestID(), Domain: domain, Limit: limit, Offset: offset})108 }{RequestID: nextRequestID(), Domain: domain, Limit: limit, Offset: offset})
109 }109 }
110 110
111-func (c *Client) TimelineWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) {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+ }
112 beforeCursor := ""118 beforeCursor := ""
113 if !before.IsZero() {119 if !before.IsZero() {
114 beforeCursor = before.Format(time.RFC3339Nano)120 beforeCursor = before.Format(time.RFC3339Nano)
@@ -116,8 +122,9 @@ func (c *Client) TimelineWindow(ctx context.Context, before time.Time, domain st
116 return request[*entity.HistoryWindow](c, ctx, "history_timeline_window", struct {122 return request[*entity.HistoryWindow](c, ctx, "history_timeline_window", struct {
117 RequestID string `json:"requestId"`123 RequestID string `json:"requestId"`
118 Before string `json:"before,omitempty"`124 Before string `json:"before,omitempty"`
125+ BeforeID int64 `json:"beforeId,omitempty"`
119 Domain string `json:"domain,omitempty"`126 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)})
121 }128 }
122 129
123 func (c *Client) Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error) {130 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) {
135 native := newTransportRecorder(t, true, []byte(`{"requestId":"req-window","success":true}`))135 native := newTransportRecorder(t, true, []byte(`{"requestId":"req-window","success":true}`))
136 client := NewClient(native, nil)136 client := NewClient(native, nil)
137 137
138- _, err := client.TimelineWindow(context.Background(), time.Time{}, "")138+ _, err := client.TimelineWindow(context.Background(), time.Time{}, 0, "")
139 if err != nil {139 if err != nil {
140 t.Fatalf("TimelineWindow() error = %v", err)140 t.Fatalf("TimelineWindow() error = %v", err)
141 }141 }
@@ -152,7 +152,59 @@ func TestClientTimelineWindowOmitsZeroCursor(t *testing.T) {
152 t.Fatalf("unmarshal payload: %v", err)152 t.Fatalf("unmarshal payload: %v", err)
153 }153 }
154 if _, ok := payload["before"]; ok {154 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")
156 }208 }
157 }209 }
158 210
M internal/ui/app.go +22 -1
@@ -220,6 +220,14 @@ func New(deps *Dependencies) (*App, error) {
220 glib.IdleAdd(&cb, 0)220 glib.IdleAdd(&cb, 0)
221 })221 })
222 }222 }
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+
223 if faviconSetter, ok := deps.Engine.(port.SystemviewFaviconResolverSetter); ok {231 if faviconSetter, ok := deps.Engine.(port.SystemviewFaviconResolverSetter); ok {
224 resolver := deps.SystemviewFaviconResolver232 resolver := deps.SystemviewFaviconResolver
225 if resolver == nil {233 if resolver == nil {
@@ -249,6 +257,10 @@ func New(deps *Dependencies) (*App, error) {
249 },257 },
250 HandlerDeps: deps.HandlerDeps,258 HandlerDeps: deps.HandlerDeps,
251 }); err != nil {259 }); err != nil {
260+ cancel(err)
261+ if deps.HistoryRecorderUC != nil {
262+ deps.HistoryRecorderUC.Close()
263+ }
252 return nil, err264 return nil, err
253 }265 }
254 266
@@ -3145,6 +3157,7 @@ func (a *App) onShutdown(ctx context.Context) {
3145 a.cancel(errors.New("application shutdown"))3157 a.cancel(errors.New("application shutdown"))
3146 3158
3147 // Cleanup resources3159 // Cleanup resources
3160+ a.closeHistoryRecorder()
3148 if a.faviconAdapter != nil {3161 if a.faviconAdapter != nil {
3149 a.faviconAdapter.Close()3162 a.faviconAdapter.Close()
3150 }3163 }
@@ -3183,6 +3196,13 @@ func (a *App) onShutdown(ctx context.Context) {
3183 log.Info().Msg("application shutdown complete")3196 log.Info().Msg("application shutdown complete")
3184 }3197 }
3185 3198
3199+func (a *App) closeHistoryRecorder() {
3200+ if a.deps == nil || a.deps.HistoryRecorderUC == nil {
3201+ return
3202+ }
3203+ a.deps.HistoryRecorderUC.Close()
3204+}
3205+
3186 // initContentCoordinator creates the content coordinator and wires its optional dependencies.3206 // initContentCoordinator creates the content coordinator and wires its optional dependencies.
3187 func (a *App) initContentCoordinator(3207 func (a *App) initContentCoordinator(
3188 ctx context.Context,3208 ctx context.Context,
@@ -3396,9 +3416,10 @@ func (a *App) initCoordinators(ctx context.Context) {
3396 a.extractPaneToTabListUC = usecase.NewExtractPaneToTabListUseCase(a.generateID)3416 a.extractPaneToTabListUC = usecase.NewExtractPaneToTabListUseCase(a.generateID)
3397 3417
3398 // 4. Navigation Coordinator3418 // 4. Navigation Coordinator
3399- a.navCoord = coordinator.NewNavigationCoordinator(3419+ a.navCoord = coordinator.NewNavigationCoordinatorWithHistoryRecorder(
3400 ctx,3420 ctx,
3401 a.deps.NavigateUC,3421 a.deps.NavigateUC,
3422+ a.deps.HistoryRecorderUC,
3402 a.contentCoord,3423 a.contentCoord,
3403 )3424 )
3404 a.wsCoord.SetOnPaneClosed(func(paneID entity.PaneID) {3425 a.wsCoord.SetOnPaneClosed(func(paneID entity.PaneID) {
M internal/ui/app_clipboard_test.go +32 -0
@@ -2,12 +2,15 @@ package ui
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "testing"6 "testing"
6 7
7 "github.com/bnema/dumber/internal/application/dto"8 "github.com/bnema/dumber/internal/application/dto"
8 "github.com/bnema/dumber/internal/application/port"9 "github.com/bnema/dumber/internal/application/port"
9 portmocks "github.com/bnema/dumber/internal/application/port/mocks"10 portmocks "github.com/bnema/dumber/internal/application/port/mocks"
11+ "github.com/bnema/dumber/internal/application/usecase"
10 "github.com/bnema/dumber/internal/domain/entity"12 "github.com/bnema/dumber/internal/domain/entity"
13+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
11 "github.com/stretchr/testify/mock"14 "github.com/stretchr/testify/mock"
12 "github.com/stretchr/testify/require"15 "github.com/stretchr/testify/require"
13 )16 )
@@ -45,6 +48,35 @@ func TestNew_DoesNotWireClipboardOrchestratorWhenClipboardMissing(t *testing.T)
45 require.Nil(t, registerDeps.ClipboardTextOrchestrator)48 require.Nil(t, registerDeps.ClipboardTextOrchestrator)
46 }49 }
47 50
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+
48 func TestNew_WiresClipboardOrchestratorWhenClipboardPresent(t *testing.T) {80 func TestNew_WiresClipboardOrchestratorWhenClipboardPresent(t *testing.T) {
49 ctx := context.Background()81 ctx := context.Background()
50 engine := portmocks.NewMockEngine(t)82 engine := portmocks.NewMockEngine(t)
M internal/ui/browser_window.go +22 -20
@@ -20,26 +20,27 @@ import (
20 )20 )
21 21
22 type browserWindow struct {22 type browserWindow struct {
23- id string23+ id string
24- initialURL string24+ initialURL string
25- tabs *entity.TabList // per-window tab list (single source of truth for tab state)25+ tabs *entity.TabList // per-window tab list (single source of truth for tab state)
26- mainWindow *window.MainWindow26+ mainWindow *window.MainWindow
27- appToaster *component.Toaster27+ appToaster *component.Toaster
28- modeToaster *component.Toaster28+ modeToaster *component.Toaster
29- touchpadNavIndicator *component.TouchpadNavigationIndicator29+ touchpadNavIndicator *component.TouchpadNavigationIndicator
30- borderMgr *focus.BorderManager30+ borderMgr *focus.BorderManager
31- sessionManager *component.SessionManager31+ sessionManager *component.SessionManager
32- tabPicker *component.TabPicker32+ tabPicker *component.TabPicker
33- tabPickerWidget layout.Widget33+ tabPickerWidget layout.Widget
34- tabPickerPaneID entity.PaneID34+ tabPickerPaneID entity.PaneID
35- insertAccentUC *usecase.InsertAccentUseCase35+ insertAccentUC *usecase.InsertAccentUseCase
36- accentPicker *component.AccentPicker36+ accentPicker *component.AccentPicker
37- keyboardHandler *input.KeyboardHandler37+ keyboardHandler *input.KeyboardHandler
38- globalShortcutHandler *input.GlobalShortcutHandler38+ globalShortcutHandler *input.GlobalShortcutHandler
39- permissionDialog port.PermissionDialogPresenter39+ permissionDialog port.PermissionDialogPresenter
40- webrtcIndicator *component.WebRTCPermissionIndicator40+ webrtcIndicator *component.WebRTCPermissionIndicator
41- historySidebar *component.HistorySidebar41+ historySidebar *component.HistorySidebar
42- sidebarVisible bool42+ historySidebarReloader historySidebarReloader
43+ sidebarVisible bool
43 }44 }
44 45
45 func (bw *browserWindow) detachInputForDestroy() {46 func (bw *browserWindow) detachInputForDestroy() {
@@ -90,6 +91,7 @@ func (bw *browserWindow) clearShellState() {
90 bw.permissionDialog = nil91 bw.permissionDialog = nil
91 bw.webrtcIndicator = nil92 bw.webrtcIndicator = nil
92 bw.historySidebar = nil93 bw.historySidebar = nil
94+ bw.historySidebarReloader = nil
93 }95 }
94 96
95 func (bw *browserWindow) initChrome(ctx context.Context, a *App) {97 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
2 2
3 import (3 import (
4 "context"4 "context"
5+ "reflect"
6+ "sync"
5 "testing"7 "testing"
8+ "unsafe"
6 9
10+ "github.com/bnema/dumber/internal/application/dto"
7 "github.com/bnema/dumber/internal/application/usecase"11 "github.com/bnema/dumber/internal/application/usecase"
8 "github.com/bnema/dumber/internal/domain/entity"12 "github.com/bnema/dumber/internal/domain/entity"
9 "github.com/bnema/dumber/internal/infrastructure/config"13 "github.com/bnema/dumber/internal/infrastructure/config"
14+ "github.com/bnema/dumber/internal/shared/syncdispatch"
10 "github.com/bnema/dumber/internal/ui/component"15 "github.com/bnema/dumber/internal/ui/component"
11 "github.com/bnema/dumber/internal/ui/coordinator"16 "github.com/bnema/dumber/internal/ui/coordinator"
12 contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"17 contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"
13 "github.com/bnema/dumber/internal/ui/dispatcher"18 "github.com/bnema/dumber/internal/ui/dispatcher"
14 "github.com/bnema/dumber/internal/ui/input"19 "github.com/bnema/dumber/internal/ui/input"
15 "github.com/bnema/dumber/internal/ui/window"20 "github.com/bnema/dumber/internal/ui/window"
21+ "github.com/bnema/puregotk/v4/glib"
16 "github.com/stretchr/testify/assert"22 "github.com/stretchr/testify/assert"
17 "github.com/stretchr/testify/require"23 "github.com/stretchr/testify/require"
18 )24 )
@@ -889,3 +895,133 @@ func TestBrowserWindow_ApplySidebarWidthConfig_NilDepsIsSafe(t *testing.T) {
889 app := &App{deps: nil}895 app := &App{deps: nil}
890 require.NotPanics(t, func() { bw.applySidebarWidthConfig(app) })896 require.NotPanics(t, func() { bw.applySidebarWidthConfig(app) })
891 }897 }
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 (
36 // "January 2, 2006" (other years). Within each group entries remain in36 // "January 2, 2006" (other years). Within each group entries remain in
37 // the order they arrived (assumed most-recent-first).37 // the order they arrived (assumed most-recent-first).
38 func groupHistoryByDay(entries []*entity.HistoryEntry) []historyGroup {38 func groupHistoryByDay(entries []*entity.HistoryEntry) []historyGroup {
39+ return groupHistoryByDayAt(entries, time.Now())
40+}
41+
42+func groupHistoryByDayAt(entries []*entity.HistoryEntry, now time.Time) []historyGroup {
39 if len(entries) == 0 {43 if len(entries) == 0 {
40 return nil44 return nil
41 }45 }
42 46
43- now := time.Now()
44 local := now.Location()47 local := now.Location()
45 todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, local)48 todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, local)
46 49
@@ -108,7 +111,10 @@ func readableURL(rawURL string) string {
108 // relativeTime returns a compact relative time label for a timestamp.111 // relativeTime returns a compact relative time label for a timestamp.
109 // Examples: "2m ago", "1h ago", "3d ago", "Jan 2".112 // Examples: "2m ago", "1h ago", "3d ago", "Jan 2".
110 func relativeTime(t time.Time) string {113 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 {
112 d := now.Sub(t)118 d := now.Sub(t)
113 119
114 switch {120 switch {
M internal/ui/component/history_model_test.go +4 -4
@@ -46,12 +46,12 @@ func TestGroupHistoryByDay_TodayYesterdayOlder(t *testing.T) {
46 }46 }
47 47
48 func TestGroupHistoryByDay_SameDayEntriesGrouped(t *testing.T) {48 func TestGroupHistoryByDay_SameDayEntriesGrouped(t *testing.T) {
49- now := time.Now()49+ now := time.Date(2026, time.June, 24, 12, 0, 0, 0, time.Local)
50 entries := []*entity.HistoryEntry{50 entries := []*entity.HistoryEntry{
51 {ID: 1, URL: "https://a.com", Title: "A", LastVisited: now},51 {ID: 1, URL: "https://a.com", Title: "A", LastVisited: now},
52 {ID: 2, URL: "https://b.com", Title: "B", LastVisited: now.Add(-time.Hour)},52 {ID: 2, URL: "https://b.com", Title: "B", LastVisited: now.Add(-time.Hour)},
53 }53 }
54- groups := groupHistoryByDay(entries)54+ groups := groupHistoryByDayAt(entries, now)
55 require.Len(t, groups, 1)55 require.Len(t, groups, 1)
56 assert.Equal(t, "Today", groups[0].Label)56 assert.Equal(t, "Today", groups[0].Label)
57 assert.Len(t, groups[0].Entries, 2)57 assert.Len(t, groups[0].Entries, 2)
@@ -106,13 +106,13 @@ func TestGroupHistoryByDay_CrossYearDifferentLabels(t *testing.T) {
106 }106 }
107 107
108 func TestGroupHistoryByDay_MaintainsInputOrderWithinDay(t *testing.T) {108 func TestGroupHistoryByDay_MaintainsInputOrderWithinDay(t *testing.T) {
109- now := time.Now()109+ now := time.Date(2026, time.June, 24, 12, 0, 0, 0, time.Local)
110 entries := []*entity.HistoryEntry{110 entries := []*entity.HistoryEntry{
111 {ID: 1, URL: "https://first.com", Title: "First", LastVisited: now},111 {ID: 1, URL: "https://first.com", Title: "First", LastVisited: now},
112 {ID: 2, URL: "https://second.com", Title: "Second", LastVisited: now.Add(-30 * time.Minute)},112 {ID: 2, URL: "https://second.com", Title: "Second", LastVisited: now.Add(-30 * time.Minute)},
113 {ID: 3, URL: "https://third.com", Title: "Third", LastVisited: now.Add(-2 * time.Hour)},113 {ID: 3, URL: "https://third.com", Title: "Third", LastVisited: now.Add(-2 * time.Hour)},
114 }114 }
115- groups := groupHistoryByDay(entries)115+ groups := groupHistoryByDayAt(entries, now)
116 require.Len(t, groups, 1)116 require.Len(t, groups, 1)
117 require.Len(t, groups[0].Entries, 3)117 require.Len(t, groups[0].Entries, 3)
118 // Entries within the same day maintain input order118 // Entries within the same day maintain input order
M internal/ui/component/history_sidebar.go +49 -7
@@ -3,6 +3,7 @@ package component
3 import (3 import (
4 "context"4 "context"
5 "sync"5 "sync"
6+ "time"
6 7
7 "github.com/bnema/puregotk/v4/glib"8 "github.com/bnema/puregotk/v4/glib"
8 "github.com/bnema/puregotk/v4/gtk"9 "github.com/bnema/puregotk/v4/gtk"
@@ -19,6 +20,8 @@ const (
19 sidebarMinWidth = 28020 sidebarMinWidth = 280
20 sidebarDefaultWidth = 32021 sidebarDefaultWidth = 320
21 sidebarSearchDebounceMs = 15022 sidebarSearchDebounceMs = 150
23+ sidebarReloadDebounceMs = 200
24+ sidebarRelativeTickMs = 60 * 1000
22 sidebarPageSize = 100 // entries fetched per page25 sidebarPageSize = 100 // entries fetched per page
23 sidebarSearchLimit = 100 // max FTS search results26 sidebarSearchLimit = 100 // max FTS search results
24 )27 )
@@ -86,8 +89,14 @@ type HistorySidebar struct {
86 prevScrollValue float6489 prevScrollValue float64
87 prevSelectedURL string90 prevSelectedURL string
88 91
89- // Search debounce timer92+ // Search/reload/relative-time timers. GLib source IDs are owned and
90- debounceTimer uint93+ // 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
91 100
92 // Retained callbacks101 // Retained callbacks
93 retainedCallbacks []interface{}102 retainedCallbacks []interface{}
@@ -95,6 +104,9 @@ type HistorySidebar struct {
95 // idleScheduler dispatches work onto the GTK main thread.104 // idleScheduler dispatches work onto the GTK main thread.
96 // Tests may override it to exercise scheduled callbacks deterministically.105 // Tests may override it to exercise scheduled callbacks deterministically.
97 idleScheduler func(glib.SourceFunc)106 idleScheduler func(glib.SourceFunc)
107+ timeoutAdd func(uint, glib.SourceFunc) uint
108+ sourceRemove func(uint)
109+ now func() time.Time
98 110
99 // Context111 // Context
100 ctx context.Context112 ctx context.Context
@@ -202,6 +214,10 @@ func (hs *HistorySidebar) Destroy() {
202 hs.destroyed = true214 hs.destroyed = true
203 timerID := hs.debounceTimer215 timerID := hs.debounceTimer
204 hs.debounceTimer = 0216 hs.debounceTimer = 0
217+ reloadTimerID := hs.cancelReloadDebounceLocked()
218+ tickerID := hs.relativeTimeTicker
219+ hs.relativeTimeTicker = 0
220+ hs.clearRelativeTimeBindingsLocked()
205 hs.mu.Unlock()221 hs.mu.Unlock()
206 222
207 if hs.cancel != nil {223 if hs.cancel != nil {
@@ -209,7 +225,13 @@ func (hs *HistorySidebar) Destroy() {
209 }225 }
210 226
211 if timerID != 0 {227 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)
213 }235 }
214 }236 }
215 237
@@ -239,10 +261,17 @@ func (hs *HistorySidebar) Show() {
239 return261 return
240 }262 }
241 263
264+ reloadTimerID := hs.reloadDebounceTimer
265+ hs.reloadDebounceTimer = 0
242 hs.outerBox.SetVisible(true)266 hs.outerBox.SetVisible(true)
243 hs.visible = true267 hs.visible = true
244 hs.mu.Unlock()268 hs.mu.Unlock()
245 269
270+ if reloadTimerID != 0 {
271+ hs.removeSource(reloadTimerID)
272+ }
273+ hs.startRelativeTimeTicker()
274+
246 // Schedule a background reload so the sidebar shows fresh data275 // Schedule a background reload so the sidebar shows fresh data
247 // when it becomes visible, not stale data captured at init time.276 // when it becomes visible, not stale data captured at init time.
248 reloadCb := glib.SourceFunc(func(uintptr) bool {277 reloadCb := glib.SourceFunc(func(uintptr) bool {
@@ -275,14 +304,27 @@ func (hs *HistorySidebar) Show() {
275 // Hide hides the sidebar.304 // Hide hides the sidebar.
276 func (hs *HistorySidebar) Hide() {305 func (hs *HistorySidebar) Hide() {
277 hs.mu.Lock()306 hs.mu.Lock()
278- defer hs.mu.Unlock()307+ if hs.destroyed {
279-308+ hs.mu.Unlock()
280- if hs.outerBox == nil || hs.destroyed {
281 return309 return
282 }310 }
283 311
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+ }
285 hs.visible = false319 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+ }
286 }328 }
287 329
288 // IsVisible returns whether the sidebar is visible.330 // 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() {
184 184
185 // restoreScrollAndSelection restores the previously saved scroll position and185 // restoreScrollAndSelection restores the previously saved scroll position and
186 // selection after a rebuild. Called on the GTK main thread.186 // selection after a rebuild. Called on the GTK main thread.
187-func (hs *HistorySidebar) restoreScrollAndSelection() {187+func (hs *HistorySidebar) restoreScrollAndSelectionInListBox(listBox *gtk.ListBox) {
188 // Restore selection first (changes scroll position)188 // Restore selection first (changes scroll position)
189 if hs.prevSelectedURL != "" {189 if hs.prevSelectedURL != "" {
190- hs.selectRowByURL(hs.prevSelectedURL)190+ hs.selectRowByURLInListBox(listBox, hs.prevSelectedURL)
191 }191 }
192 192
193 // Then restore scroll position if we have one193 // Then restore scroll position if we have one
@@ -232,12 +232,12 @@ func (hs *HistorySidebar) entryURLAtIndex(index int) string {
232 }232 }
233 233
234 // selectRowByURL finds and selects a row whose URL matches.234 // selectRowByURL finds and selects a row whose URL matches.
235-func (hs *HistorySidebar) selectRowByURL(url string) {235+func (hs *HistorySidebar) selectRowByURLInListBox(listBox *gtk.ListBox, url string) {
236- if url == "" || hs.listBox == nil {236+ if url == "" || listBox == nil {
237 return237 return
238 }238 }
239 for i := 0; ; i++ {239 for i := 0; ; i++ {
240- row := hs.listBox.GetRowAtIndex(i)240+ row := listBox.GetRowAtIndex(i)
241 if row == nil {241 if row == nil {
242 break242 break
243 }243 }
@@ -245,7 +245,7 @@ func (hs *HistorySidebar) selectRowByURL(url string) {
245 continue245 continue
246 }246 }
247 if hs.getRowURL(row) == url {247 if hs.getRowURL(row) == url {
248- hs.listBox.SelectRow(row)248+ listBox.SelectRow(row)
249 return249 return
250 }250 }
251 }251 }
@@ -280,20 +280,20 @@ func (hs *HistorySidebar) onSearchChanged() {
280 hs.mu.Unlock()280 hs.mu.Unlock()
281 281
282 if oldTimer != 0 {282 if oldTimer != 0 {
283- glib.SourceRemove(oldTimer)283+ hs.removeSource(oldTimer)
284 }284 }
285 285
286 filterCb := glib.SourceFunc(func(uintptr) bool {286 filterCb := glib.SourceFunc(func(uintptr) bool {
287 hs.applyFilter()287 hs.applyFilter()
288 return false288 return false
289 })289 })
290- timerID := glib.TimeoutAdd(uint(sidebarSearchDebounceMs), &filterCb, 0)290+ timerID := hs.addTimeout(sidebarSearchDebounceMs, filterCb)
291 291
292 hs.mu.Lock()292 hs.mu.Lock()
293 if hs.destroyed {293 if hs.destroyed {
294 hs.mu.Unlock()294 hs.mu.Unlock()
295 if timerID != 0 {295 if timerID != 0 {
296- glib.SourceRemove(timerID)296+ hs.removeSource(timerID)
297 }297 }
298 return298 return
299 }299 }
@@ -419,6 +419,9 @@ func (hs *HistorySidebar) scheduleClearList() {
419 if destroyed || listBox == nil {419 if destroyed || listBox == nil {
420 return false420 return false
421 }421 }
422+ hs.mu.Lock()
423+ hs.clearRelativeTimeBindingsLocked()
424+ hs.mu.Unlock()
422 listBox.RemoveAll()425 listBox.RemoveAll()
423 return false426 return false
424 })427 })
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 (
14 // rebuildList clears and repopulates the list box from current groups.14 // rebuildList clears and repopulates the list box from current groups.
15 // Must be called on the GTK main thread. Preserves scroll and selection.15 // Must be called on the GTK main thread. Preserves scroll and selection.
16 func (hs *HistorySidebar) rebuildList() {16 func (hs *HistorySidebar) rebuildList() {
17- hs.mu.RLock()17+ hs.mu.Lock()
18 if hs.destroyed || hs.listBox == nil {18 if hs.destroyed || hs.listBox == nil {
19- hs.mu.RUnlock()19+ hs.mu.Unlock()
20 return20 return
21 }21 }
22- rows := hs.displayRows22+ listBox := hs.listBox
23+ rows := append([]historyDisplayRow(nil), hs.displayRows...)
23 query := hs.currentQuery24 query := hs.currentQuery
24 hasSearchResults := hs.searchResults != nil25 hasSearchResults := hs.searchResults != nil
25 searchDone := hs.searchDone26 searchDone := hs.searchDone
26 totalLoaded := hs.totalLoaded27 totalLoaded := hs.totalLoaded
27- hs.mu.RUnlock()28+ isLoading := hs.isLoading
29+ hs.clearRelativeTimeBindingsLocked()
30+ hs.mu.Unlock()
28 31
29 // Remove all rows32 // Remove all rows
30- hs.listBox.RemoveAll()33+ listBox.RemoveAll()
31 34
32 if len(rows) == 0 {35 if len(rows) == 0 {
33 if !hasSearchResults && totalLoaded == 0 {36 if !hasSearchResults && totalLoaded == 0 {
34 // Browse has not loaded yet AND no search has completed.37 // Browse has not loaded yet AND no search has completed.
35- hs.showLoadingOrEmpty(query, searchDone)38+ hs.showLoadingOrEmpty(listBox, query, searchDone, isLoading)
36 return39 return
37 }40 }
38 // Search completed with 0 results, or browse loaded but empty (no history).41 // Search completed with 0 results, or browse loaded but empty (no history).
39- hs.showEmptyState(query)42+ hs.showEmptyState(listBox, query)
40- hs.restoreScrollAndSelection()43+ hs.restoreScrollAndSelectionInListBox(listBox)
41 return44 return
42 }45 }
43 46
44 for _, row := range rows {47 for _, row := range rows {
45 switch row.Kind {48 switch row.Kind {
46 case historyDisplayRowHeader:49 case historyDisplayRowHeader:
47- hs.appendGroupHeader(row.Label)50+ hs.appendGroupHeader(listBox, row.Label)
48 case historyDisplayRowEntry:51 case historyDisplayRowEntry:
49- hs.appendEntryRow(row.Entry)52+ hs.appendEntryRow(listBox, row.Entry)
50 }53 }
51 }54 }
52 55
53- hs.listBox.Show()56+ listBox.Show()
54 57
55 // Restore previous scroll position and selection58 // Restore previous scroll position and selection
56- hs.restoreScrollAndSelection()59+ hs.restoreScrollAndSelectionInListBox(listBox)
57 60
58 // If no selection was restored and this is the first load, select first entry61 // If no selection was restored and this is the first load, select first entry
59- hs.ensureAtLeastOneSelection()62+ hs.ensureAtLeastOneSelectionInListBox(listBox)
60 }63 }
61 64
62-func (hs *HistorySidebar) showLoadingOrEmpty(query string, searchDone bool) {65+func (hs *HistorySidebar) showLoadingOrEmpty(listBox *gtk.ListBox, query string, searchDone, isLoading bool) {
63 label := gtk.NewLabel(nil)66 label := gtk.NewLabel(nil)
64 if label == nil {67 if label == nil {
65 return68 return
66 }69 }
67 label.AddCssClass("history-sidebar-loading")70 label.AddCssClass("history-sidebar-loading")
68 71
69- hs.mu.RLock()
70- isLoading := hs.isLoading
71- hs.mu.RUnlock()
72-
73 switch {72 switch {
74 case isLoading && query == "":73 case isLoading && query == "":
75 label.SetText("Loading history...")74 label.SetText("Loading history...")
@@ -92,14 +91,14 @@ func (hs *HistorySidebar) showLoadingOrEmpty(query string, searchDone bool) {
92 row.SetCanFocus(false)91 row.SetCanFocus(false)
93 row.SetActivatable(false)92 row.SetActivatable(false)
94 row.SetChild(&label.Widget)93 row.SetChild(&label.Widget)
95- hs.listBox.Append(&row.Widget)94+ listBox.Append(&row.Widget)
96 }95 }
97 96
98 func noResultsText(query string) string {97 func noResultsText(query string) string {
99 return "No results for \"" + query + "\""98 return "No results for \"" + query + "\""
100 }99 }
101 100
102-func (hs *HistorySidebar) showEmptyState(query string) {101+func (hs *HistorySidebar) showEmptyState(listBox *gtk.ListBox, query string) {
103 label := gtk.NewLabel(nil)102 label := gtk.NewLabel(nil)
104 if label == nil {103 if label == nil {
105 return104 return
@@ -123,11 +122,11 @@ func (hs *HistorySidebar) showEmptyState(query string) {
123 row.SetCanFocus(false)122 row.SetCanFocus(false)
124 row.SetActivatable(false)123 row.SetActivatable(false)
125 row.SetChild(&label.Widget)124 row.SetChild(&label.Widget)
126- hs.listBox.Append(&row.Widget)125+ listBox.Append(&row.Widget)
127 }126 }
128 127
129 // appendGroupHeader adds a non-selectable group header label to the list.128 // 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) {
131 label := gtk.NewLabel(&labelText)130 label := gtk.NewLabel(&labelText)
132 if label == nil {131 if label == nil {
133 return132 return
@@ -144,11 +143,11 @@ func (hs *HistorySidebar) appendGroupHeader(labelText string) {
144 row.SetCanFocus(false)143 row.SetCanFocus(false)
145 row.SetActivatable(false)144 row.SetActivatable(false)
146 row.SetChild(&label.Widget)145 row.SetChild(&label.Widget)
147- hs.listBox.Append(&row.Widget)146+ listBox.Append(&row.Widget)
148 }147 }
149 148
150 // appendEntryRow adds a selectable two-line entry row to the list.149 // 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) {
152 if entry == nil {151 if entry == nil {
153 return152 return
154 }153 }
@@ -192,7 +191,7 @@ func (hs *HistorySidebar) appendEntryRow(entry *entity.HistoryEntry) {
192 return191 return
193 }192 }
194 timeLabel.AddCssClass("history-sidebar-row-time")193 timeLabel.AddCssClass("history-sidebar-row-time")
195- timeLabel.SetText(relativeTime(entry.LastVisited))194+ timeLabel.SetText(relativeTimeAt(entry.LastVisited, hs.currentTime()))
196 timeLabel.SetXalign(1.0)195 timeLabel.SetXalign(1.0)
197 196
198 subBox.Append(&urlLabel.Widget)197 subBox.Append(&urlLabel.Widget)
@@ -213,21 +212,22 @@ func (hs *HistorySidebar) appendEntryRow(entry *entity.HistoryEntry) {
213 row.SetFocusOnClick(true)212 row.SetFocusOnClick(true)
214 row.SetChild(&rowBox.Widget)213 row.SetChild(&rowBox.Widget)
215 214
216- hs.listBox.Append(&row.Widget)215+ listBox.Append(&row.Widget)
216+ hs.bindRelativeTimeLabel(timeLabel, entry.LastVisited)
217 }217 }
218 218
219 // ensureAtLeastOneSelection selects the first selectable row if nothing is selected.219 // ensureAtLeastOneSelection selects the first selectable row if nothing is selected.
220-func (hs *HistorySidebar) ensureAtLeastOneSelection() {220+func (hs *HistorySidebar) ensureAtLeastOneSelectionInListBox(listBox *gtk.ListBox) {
221- if hs.listBox.GetSelectedRow() != nil {221+ if listBox == nil || listBox.GetSelectedRow() != nil {
222 return222 return
223 }223 }
224 for i := 0; ; i++ {224 for i := 0; ; i++ {
225- row := hs.listBox.GetRowAtIndex(i)225+ row := listBox.GetRowAtIndex(i)
226 if row == nil {226 if row == nil {
227 break227 break
228 }228 }
229 if row.GetSelectable() {229 if row.GetSelectable() {
230- hs.listBox.SelectRow(row)230+ listBox.SelectRow(row)
231 return231 return
232 }232 }
233 }233 }
M internal/ui/coordinator/navigation.go +20 -8
@@ -22,6 +22,7 @@ type OmniboxProvider interface {
22 type NavigationCoordinator struct {22 type NavigationCoordinator struct {
23 contextProvider func() context.Context23 contextProvider func() context.Context
24 navigateUC *usecase.NavigateUseCase24 navigateUC *usecase.NavigateUseCase
25+ historyRecorder *usecase.HistoryRecorderUseCase
25 contentCoord *content.Coordinator26 contentCoord *content.Coordinator
26 omniboxProvider OmniboxProvider27 omniboxProvider OmniboxProvider
27 }28 }
@@ -33,6 +34,16 @@ func NewNavigationCoordinator(
33 ctx context.Context,34 ctx context.Context,
34 navigateUC *usecase.NavigateUseCase,35 navigateUC *usecase.NavigateUseCase,
35 contentCoord *content.Coordinator,36 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,
36 ) *NavigationCoordinator {47 ) *NavigationCoordinator {
37 log := logging.FromContext(ctx)48 log := logging.FromContext(ctx)
38 log.Debug().Msg("creating navigation coordinator")49 log.Debug().Msg("creating navigation coordinator")
@@ -42,8 +53,9 @@ func NewNavigationCoordinator(
42 contextProvider: func() context.Context {53 contextProvider: func() context.Context {
43 return logging.WithContext(context.Background(), callbackLogger)54 return logging.WithContext(context.Background(), callbackLogger)
44 },55 },
45- navigateUC: navigateUC,56+ navigateUC: navigateUC,
46- contentCoord: contentCoord,57+ historyRecorder: historyRecorder,
58+ contentCoord: contentCoord,
47 }59 }
48 }60 }
49 61
@@ -239,28 +251,28 @@ func (c *NavigationCoordinator) PrintWebView(ctx context.Context, wv port.WebVie
239 251
240 // UpdateHistoryTitle updates the title of a history entry after page load.252 // UpdateHistoryTitle updates the title of a history entry after page load.
241 func (c *NavigationCoordinator) UpdateHistoryTitle(ctx context.Context, paneID entity.PaneID, url, title string) {253 func (c *NavigationCoordinator) UpdateHistoryTitle(ctx context.Context, paneID entity.PaneID, url, title string) {
242- if c.navigateUC == nil {254+ if c.historyRecorder == nil {
243 return255 return
244 }256 }
245 257
246- c.navigateUC.UpdateHistoryTitle(ctx, url, title)258+ c.historyRecorder.UpdateHistoryTitle(ctx, url, title)
247 }259 }
248 260
249 // RecordHistory records a URL in history on page commit.261 // RecordHistory records a URL in history on page commit.
250 func (c *NavigationCoordinator) RecordHistory(ctx context.Context, paneID entity.PaneID, url string) {262 func (c *NavigationCoordinator) RecordHistory(ctx context.Context, paneID entity.PaneID, url string) {
251- if c.navigateUC == nil {263+ if c.historyRecorder == nil {
252 return264 return
253 }265 }
254 266
255- c.navigateUC.RecordHistory(ctx, string(paneID), url)267+ c.historyRecorder.RecordHistory(ctx, string(paneID), url)
256 }268 }
257 269
258 // ClearPaneHistory clears per-pane navigation history deduplication state.270 // ClearPaneHistory clears per-pane navigation history deduplication state.
259 func (c *NavigationCoordinator) ClearPaneHistory(paneID entity.PaneID) {271 func (c *NavigationCoordinator) ClearPaneHistory(paneID entity.PaneID) {
260- if c.navigateUC == nil {272+ if c.historyRecorder == nil {
261 return273 return
262 }274 }
263- c.navigateUC.ClearPaneHistory(string(paneID))275+ c.historyRecorder.ClearPaneHistory(string(paneID))
264 }276 }
265 277
266 // NotifyZoomChanged updates the omnibox zoom indicator.278 // NotifyZoomChanged updates the omnibox zoom indicator.
M internal/ui/deps.go +9 -8
@@ -51,14 +51,15 @@ type Dependencies struct {
51 FilterRepo repository.ContentWhitelistRepository51 FilterRepo repository.ContentWhitelistRepository
52 52
53 // Use Cases53 // Use Cases
54- TabsUC *usecase.ManageTabsUseCase54+ TabsUC *usecase.ManageTabsUseCase
55- PanesUC *usecase.ManagePanesUseCase55+ PanesUC *usecase.ManagePanesUseCase
56- NavigateUC *usecase.NavigateUseCase56+ NavigateUC *usecase.NavigateUseCase
57- ZoomUC *usecase.ManageZoomUseCase57+ HistoryRecorderUC *usecase.HistoryRecorderUseCase
58- PermissionUC *usecase.HandlePermissionUseCase58+ ZoomUC *usecase.ManageZoomUseCase
59- FavoritesUC *usecase.ManageFavoritesUseCase59+ PermissionUC *usecase.HandlePermissionUseCase
60- HistoryUC *usecase.SearchHistoryUseCase60+ FavoritesUC *usecase.ManageFavoritesUseCase
61- CopyURLUC *usecase.CopyURLUseCase61+ HistoryUC *usecase.SearchHistoryUseCase
62+ CopyURLUC *usecase.CopyURLUseCase
62 63
63 // Infrastructure Adapters64 // Infrastructure Adapters
64 Clipboard port.Clipboard65 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 {
38 historyOffset int38 historyOffset int
39 historyWindowBefore time.Time39 historyWindowBefore time.Time
40 historyWindowAfter time.Time40 historyWindowAfter time.Time
41+ historyCursorID int64
41 historyHasMore bool42 historyHasMore bool
42 historyNotice string43 historyNotice string
43 historyError string44 historyError string
@@ -571,6 +572,7 @@ type historyRouteSnapshot struct {
571 offset int572 offset int
572 windowBefore time.Time573 windowBefore time.Time
573 windowAfter time.Time574 windowAfter time.Time
575+ cursorID int64
574 notice string576 notice string
575 error string577 error string
576 }578 }
@@ -581,6 +583,7 @@ type historyRouteResult struct {
581 domains []*entity.DomainStat583 domains []*entity.DomainStat
582 windowBefore time.Time584 windowBefore time.Time
583 windowAfter time.Time585 windowAfter time.Time
586+ cursorID int64
584 hasMore bool587 hasMore bool
585 html string588 html string
586 }589 }
@@ -595,6 +598,7 @@ func (a *App) currentHistoryRouteSnapshotLocked() historyRouteSnapshot {
595 offset: a.historyOffset,598 offset: a.historyOffset,
596 windowBefore: a.historyWindowBefore,599 windowBefore: a.historyWindowBefore,
597 windowAfter: a.historyWindowAfter,600 windowAfter: a.historyWindowAfter,
601+ cursorID: a.historyCursorID,
598 notice: a.historyNotice,602 notice: a.historyNotice,
599 error: a.historyError,603 error: a.historyError,
600 }604 }
@@ -633,8 +637,7 @@ func (a *App) renderHistoryRouteSnapshot(ctx context.Context, snapshot historyRo
633 }637 }
634 if window != nil {638 if window != nil {
635 result.windowBefore = window.Before639 result.windowBefore = window.Before
636- result.windowAfter = window.After640+ result.windowAfter, result.cursorID, result.hasMore = historyWindowCursor(window)
637- result.hasMore = window.HasMore
638 }641 }
639 data := historyRenderData{642 data := historyRenderData{
640 Entries: entries,643 Entries: entries,
@@ -662,6 +665,20 @@ func (a *App) renderHistoryRouteSnapshot(ctx context.Context, snapshot historyRo
662 return result, nil665 return result, nil
663 }666 }
664 667
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+
665 func loadHistoryWindowSnapshot(ctx context.Context, snapshot historyRouteSnapshot) (*entity.HistoryWindow, []*entity.HistoryEntry, error) {682 func loadHistoryWindowSnapshot(ctx context.Context, snapshot historyRouteSnapshot) (*entity.HistoryWindow, []*entity.HistoryEntry, error) {
666 query := strings.TrimSpace(snapshot.query)683 query := strings.TrimSpace(snapshot.query)
667 domain := strings.TrimSpace(snapshot.domainFilter)684 domain := strings.TrimSpace(snapshot.domainFilter)
@@ -669,7 +686,7 @@ func loadHistoryWindowSnapshot(ctx context.Context, snapshot historyRouteSnapsho
669 entries, err := snapshot.history.Search(ctx, query, historySearchLimit)686 entries, err := snapshot.history.Search(ctx, query, historySearchLimit)
670 return nil, entries, err687 return nil, entries, err
671 }688 }
672- window, err := snapshot.history.TimelineWindow(ctx, snapshot.windowBefore, domain)689+ window, err := snapshot.history.TimelineWindow(ctx, snapshot.windowAfter, snapshot.cursorID, domain)
673 if err != nil {690 if err != nil {
674 return nil, nil, err691 return nil, nil, err
675 }692 }
@@ -689,6 +706,7 @@ func (a *App) commitHistoryRouteResultLocked(result historyRouteResult) {
689 a.historyDomainStats = result.domains706 a.historyDomainStats = result.domains
690 a.historyWindowBefore = result.windowBefore707 a.historyWindowBefore = result.windowBefore
691 a.historyWindowAfter = result.windowAfter708 a.historyWindowAfter = result.windowAfter
709+ a.historyCursorID = result.cursorID
692 a.historyHasMore = result.hasMore710 a.historyHasMore = result.hasMore
693 a.renderedHTML = result.html711 a.renderedHTML = result.html
694 }712 }
@@ -812,6 +830,7 @@ func (a *App) resetRouteState() {
812 a.historyOffset = 0830 a.historyOffset = 0
813 a.historyWindowBefore = time.Time{}831 a.historyWindowBefore = time.Time{}
814 a.historyWindowAfter = time.Time{}832 a.historyWindowAfter = time.Time{}
833+ a.historyCursorID = 0
815 a.historyHasMore = false834 a.historyHasMore = false
816 a.historyNotice = ""835 a.historyNotice = ""
817 a.historyError = ""836 a.historyError = ""
M internal/ui/systemviews/app_test.go +47 -9
@@ -213,16 +213,18 @@ func TestAppHandleHistoryLoadMoreAppendsOlderWindow(t *testing.T) {
213 213
214 cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC)214 cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC)
215 history := &recordingHistoryService{window: &entity.HistoryWindow{215 history := &recordingHistoryService{window: &entity.HistoryWindow{
216- Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example", Title: "Older entry", LastVisited: cursor.Add(-time.Hour)}},216+ Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example", Title: "Older entry", LastVisited: cursor.Add(-time.Hour)}},
217- Before: cursor,217+ Before: cursor,
218- After: cursor.Add(-24 * time.Hour),218+ CursorLastVisited: cursor.Add(-time.Hour),
219- HasMore: true,219+ CursorID: 2,
220+ HasMore: true,
220 }}221 }}
221 dom := &recordingActionDOM{}222 dom := &recordingActionDOM{}
222 app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})223 app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
223 app.currentRoute = RouteHistory224 app.currentRoute = RouteHistory
224 app.historyEntries = []*entity.HistoryEntry{{ID: 1, URL: "https://newer.example", Title: "Newer entry", LastVisited: cursor}}225 app.historyEntries = []*entity.HistoryEntry{{ID: 1, URL: "https://newer.example", Title: "Newer entry", LastVisited: cursor}}
225 app.historyWindowAfter = cursor226 app.historyWindowAfter = cursor
227+ app.historyCursorID = 1
226 app.historyHasMore = true228 app.historyHasMore = true
227 229
228 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{230 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
@@ -230,6 +232,9 @@ func TestAppHandleHistoryLoadMoreAppendsOlderWindow(t *testing.T) {
230 Data: map[string]string{"before": cursor.Format(time.RFC3339Nano)},232 Data: map[string]string{"before": cursor.Format(time.RFC3339Nano)},
231 }))233 }))
232 assert.Equal(t, cursor, history.windowBefore)234 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)
233 assert.Len(t, app.historyEntries, 2)238 assert.Len(t, app.historyEntries, 2)
234 assert.Contains(t, dom.AppendedHTML(), "Older entry")239 assert.Contains(t, dom.AppendedHTML(), "Older entry")
235 assert.Contains(t, dom.AppendedHTML(), `data-sv-action="history.loadMore"`)240 assert.Contains(t, dom.AppendedHTML(), `data-sv-action="history.loadMore"`)
@@ -241,16 +246,18 @@ func TestAppHandleHistoryLoadMoreRemountsWhenAppendUnavailable(t *testing.T) {
241 246
242 cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC)247 cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC)
243 history := &recordingHistoryService{window: &entity.HistoryWindow{248 history := &recordingHistoryService{window: &entity.HistoryWindow{
244- Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example", Title: "Older entry", LastVisited: cursor.Add(-time.Hour)}},249+ Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example", Title: "Older entry", LastVisited: cursor.Add(-time.Hour)}},
245- Before: cursor,250+ Before: cursor,
246- After: cursor.Add(-24 * time.Hour),251+ CursorLastVisited: cursor.Add(-time.Hour),
247- HasMore: true,252+ CursorID: 2,
253+ HasMore: true,
248 }}254 }}
249 dom := &recordingDOM{}255 dom := &recordingDOM{}
250 app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})256 app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
251 app.currentRoute = RouteHistory257 app.currentRoute = RouteHistory
252 app.historyEntries = []*entity.HistoryEntry{{ID: 1, URL: "https://newer.example", Title: "Newer entry", LastVisited: cursor}}258 app.historyEntries = []*entity.HistoryEntry{{ID: 1, URL: "https://newer.example", Title: "Newer entry", LastVisited: cursor}}
253 app.historyWindowAfter = cursor259 app.historyWindowAfter = cursor
260+ app.historyCursorID = 1
254 app.historyHasMore = true261 app.historyHasMore = true
255 262
256 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{263 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
@@ -258,11 +265,40 @@ func TestAppHandleHistoryLoadMoreRemountsWhenAppendUnavailable(t *testing.T) {
258 Data: map[string]string{"before": cursor.Format(time.RFC3339Nano)},265 Data: map[string]string{"before": cursor.Format(time.RFC3339Nano)},
259 }))266 }))
260 assert.Equal(t, cursor, history.windowBefore)267 assert.Equal(t, cursor, history.windowBefore)
268+ assert.Equal(t, int64(1), history.windowBeforeID)
261 assert.Len(t, app.historyEntries, 2)269 assert.Len(t, app.historyEntries, 2)
262 assert.Contains(t, dom.HTML(), "Older entry")270 assert.Contains(t, dom.HTML(), "Older entry")
263 assert.Contains(t, dom.HTML(), `data-sv-action="history.loadMore"`)271 assert.Contains(t, dom.HTML(), `data-sv-action="history.loadMore"`)
264 }272 }
265 273
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+
266 func TestAppHandleHistoryLoadMoreIgnoresStaleCursor(t *testing.T) {302 func TestAppHandleHistoryLoadMoreIgnoresStaleCursor(t *testing.T) {
267 t.Parallel()303 t.Parallel()
268 304
@@ -1072,6 +1108,7 @@ type recordingHistoryService struct {
1072 statsCalled bool1108 statsCalled bool
1073 window *entity.HistoryWindow1109 window *entity.HistoryWindow
1074 windowBefore time.Time1110 windowBefore time.Time
1111+ windowBeforeID int64
1075 windowDomain string1112 windowDomain string
1076 analyticsCalled bool1113 analyticsCalled bool
1077 1114
@@ -1101,9 +1138,10 @@ func (s *recordingHistoryService) TimelineByDomain(_ context.Context, domain str
1101 return s.entries, s.err1138 return s.entries, s.err
1102 }1139 }
1103 1140
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) {
1105 s.called = true1142 s.called = true
1106 s.windowBefore = before1143 s.windowBefore = before
1144+ s.windowBeforeID = beforeID
1107 s.windowDomain = domain1145 s.windowDomain = domain
1108 if s.timelineStarted != nil {1146 if s.timelineStarted != nil {
1109 s.startOnce.Do(func() { close(s.timelineStarted) })1147 s.startOnce.Do(func() { close(s.timelineStarted) })
M internal/ui/systemviews/history_actions.go +74 -45
@@ -6,6 +6,9 @@ import (
6 "strconv"6 "strconv"
7 "strings"7 "strings"
8 "time"8 "time"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/domain/entity"
9 )12 )
10 13
11 const (14 const (
@@ -167,54 +170,84 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error {
167 return nil170 return nil
168 }171 }
169 172
173+type historyLoadMoreRequest struct {
174+ before time.Time
175+ beforeID int64
176+ domain string
177+ history port.SystemviewHistoryService
178+ dom DOM
179+}
180+
170 func (a *App) handleHistoryLoadMore(ctx context.Context, event DOMAction) error {181 func (a *App) handleHistoryLoadMore(ctx context.Context, event DOMAction) error {
171 a.lockState()182 a.lockState()
172- if a.deps.History == nil {183+ req, ok, err := a.historyLoadMoreRequestLocked(event)
173- a.unlockState()184+ a.unlockState()
174- return fmt.Errorf("history service not configured")185+ if err != nil || !ok {
186+ return err
175 }187 }
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
177 a.unlockState()196 a.unlockState()
178 return nil197 return nil
179 }198 }
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+
180 before := a.historyWindowAfter218 before := a.historyWindowAfter
219+ beforeID := a.historyCursorID
181 if requested := strings.TrimSpace(event.Data["before"]); requested != "" {220 if requested := strings.TrimSpace(event.Data["before"]); requested != "" {
182 parsed, err := time.Parse(time.RFC3339Nano, requested)221 parsed, err := time.Parse(time.RFC3339Nano, requested)
183 if err != nil {222 if err != nil {
184- a.unlockState()223+ return historyLoadMoreRequest{}, false, fmt.Errorf("invalid history cursor")
185- return fmt.Errorf("invalid history cursor")
186 }224 }
187 if !before.IsZero() && !parsed.Equal(before) {225 if !before.IsZero() && !parsed.Equal(before) {
188- a.unlockState()226+ return historyLoadMoreRequest{}, false, nil
189- return nil
190 }227 }
191 before = parsed228 before = parsed
192 }229 }
193- if before.IsZero() {230+ if before.IsZero() || beforeID <= 0 {
194- before = time.Now()231+ a.historyWindowAfter = time.Time{}
195- }232+ a.historyCursorID = 0
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()
207 a.historyHasMore = false233 a.historyHasMore = false
208- a.unlockState()234+ return historyLoadMoreRequest{}, false, nil
209- return nil
210 }235 }
211 236
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) {
213 appendSkipFirstDate := lastHistoryDateKey(a.historyEntries)247 appendSkipFirstDate := lastHistoryDateKey(a.historyEntries)
214 a.historyEntries = append(a.historyEntries, window.Entries...)248 a.historyEntries = append(a.historyEntries, window.Entries...)
215 a.historyWindowBefore = window.Before249 a.historyWindowBefore = window.Before
216- a.historyWindowAfter = window.After250+ a.historyWindowAfter, a.historyCursorID, a.historyHasMore = historyWindowCursor(window)
217- a.historyHasMore = window.HasMore
218 data := historyRenderData{251 data := historyRenderData{
219 Entries: window.Entries,252 Entries: window.Entries,
220 Stats: a.historyStats,253 Stats: a.historyStats,
@@ -231,32 +264,28 @@ func (a *App) handleHistoryLoadMore(ctx context.Context, event DOMAction) error
231 Error: a.historyError,264 Error: a.historyError,
232 }265 }
233 fragment := historyTimelineAppendHTML(data)266 fragment := historyTimelineAppendHTML(data)
234- var fallbackHTML string267+ if _, ok := dom.(DOMHistoryTimelineAppender); ok {
235- if _, ok := dom.(DOMHistoryTimelineAppender); !ok {268+ return fragment, ""
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
247 }269 }
248- a.unlockState()
249 270
250- if appender, ok := dom.(DOMHistoryTimelineAppender); ok {271+ fullData := data
251- return appender.AppendHistoryTimeline(fragment)272+ fullData.Entries = a.historyEntries
252- }273+ title := historyDocumentTitle(fullData)
253- return a.mountHTML(fallbackHTML)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
254 }282 }
255 283
256 func (a *App) resetHistoryWindowState() {284 func (a *App) resetHistoryWindowState() {
257 a.historyOffset = 0285 a.historyOffset = 0
258 a.historyWindowBefore = time.Time{}286 a.historyWindowBefore = time.Time{}
259 a.historyWindowAfter = time.Time{}287 a.historyWindowAfter = time.Time{}
288+ a.historyCursorID = 0
260 a.historyHasMore = false289 a.historyHasMore = false
261 }290 }
262 291
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