detached f646d5d

Merge branch 'main' into ci/dx-next-nongo-paths

commit
f646d5d3678fb65915e5029335831e6b7176210f
author
bnema · · 2026-06-24 19:49 UTC
committer
GitHub · · 2026-06-24 19:49 UTC
merge of
966cc92 + 8c1666e · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
28 files · +571 -404
M .github/workflows/aur.yml +2 -2
@@ -23,7 +23,7 @@ jobs:
2323 cancel-in-progress: true
2424
2525 steps:
26- - uses: actions/checkout@v6
26+ - uses: actions/checkout@v7
2727 with:
2828 ref: ${{ github.event.workflow_run.head_sha }}
2929 fetch-depth: 0
@@ -123,7 +123,7 @@ jobs:
123123 cancel-in-progress: true
124124
125125 steps:
126- - uses: actions/checkout@v6
126+ - uses: actions/checkout@v7
127127 with:
128128 fetch-depth: 0
129129
@@ -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
@@ -123,7 +123,7 @@ jobs:
123 cancel-in-progress: true123 cancel-in-progress: true
124 124
125 steps:125 steps:
126- - uses: actions/checkout@v6126+ - uses: actions/checkout@v7
127 with:127 with:
128 fetch-depth: 0128 fetch-depth: 0
129 129
M .github/workflows/ci.yml +7 -7
@@ -27,7 +27,7 @@ jobs:
2727 outputs:
2828 ci: ${{ steps.filter.outputs.ci }}
2929 steps:
30- - uses: actions/checkout@v6
30+ - uses: actions/checkout@v7
3131 with:
3232 fetch-depth: 0
3333 - uses: dorny/paths-filter@v4
@@ -56,7 +56,7 @@ jobs:
5656 run: |
5757 pacman -Syu --noconfirm
5858 pacman -S --noconfirm git go webkitgtk-6.0 gtk4 gobject-introspection base-devel brotli
59- - uses: actions/checkout@v6
59+ - uses: actions/checkout@v7
6060 - name: Allow git commands in container checkout
6161 run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
6262 - name: Set up pinned Go toolchain
@@ -65,14 +65,14 @@ jobs:
6565 go-version-file: go.mod
6666 cache: false
6767 - name: Cache Go modules
68- uses: actions/cache@v5
68+ uses: actions/cache@v6
6969 with:
7070 path: ~/go/pkg/mod
7171 key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
7272 restore-keys: |
7373 ${{ runner.os }}-go-mod-
7474 - name: Cache Go build cache
75- uses: actions/cache@v5
75+ uses: actions/cache@v6
7676 with:
7777 path: ~/.cache/go-build
7878 key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}
@@ -100,21 +100,21 @@ jobs:
100100 run: |
101101 pacman -Syu --noconfirm
102102 pacman -S --noconfirm git go webkitgtk-6.0 gtk4 libadwaita gobject-introspection base-devel brotli dbus
103- - uses: actions/checkout@v6
103+ - uses: actions/checkout@v7
104104 - name: Set up pinned Go toolchain
105105 uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c
106106 with:
107107 go-version-file: go.mod
108108 cache: false
109109 - name: Cache Go modules
110- uses: actions/cache@v5
110+ uses: actions/cache@v6
111111 with:
112112 path: ~/go/pkg/mod
113113 key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
114114 restore-keys: |
115115 ${{ runner.os }}-go-mod-
116116 - name: Cache Go build cache
117- uses: actions/cache@v5
117+ uses: actions/cache@v6
118118 with:
119119 path: ~/.cache/go-build
120120 key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}
@@ -27,7 +27,7 @@ jobs:
27 outputs:27 outputs:
28 ci: ${{ steps.filter.outputs.ci }}28 ci: ${{ steps.filter.outputs.ci }}
29 steps:29 steps:
30- - uses: actions/checkout@v630+ - uses: actions/checkout@v7
31 with:31 with:
32 fetch-depth: 032 fetch-depth: 0
33 - uses: dorny/paths-filter@v433 - uses: dorny/paths-filter@v4
@@ -56,7 +56,7 @@ jobs:
56 run: |56 run: |
57 pacman -Syu --noconfirm57 pacman -Syu --noconfirm
58 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
59- - uses: actions/checkout@v659+ - uses: actions/checkout@v7
60 - name: Allow git commands in container checkout60 - name: Allow git commands in container checkout
61 run: git config --global --add safe.directory "$GITHUB_WORKSPACE"61 run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
62 - name: Set up pinned Go toolchain62 - name: Set up pinned Go toolchain
@@ -65,14 +65,14 @@ jobs:
65 go-version-file: go.mod65 go-version-file: go.mod
66 cache: false66 cache: false
67 - name: Cache Go modules67 - name: Cache Go modules
68- uses: actions/cache@v568+ uses: actions/cache@v6
69 with:69 with:
70 path: ~/go/pkg/mod70 path: ~/go/pkg/mod
71 key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}71 key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
72 restore-keys: |72 restore-keys: |
73 ${{ runner.os }}-go-mod-73 ${{ runner.os }}-go-mod-
74 - name: Cache Go build cache74 - name: Cache Go build cache
75- uses: actions/cache@v575+ uses: actions/cache@v6
76 with:76 with:
77 path: ~/.cache/go-build77 path: ~/.cache/go-build
78 key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}78 key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}
@@ -100,21 +100,21 @@ jobs:
100 run: |100 run: |
101 pacman -Syu --noconfirm101 pacman -Syu --noconfirm
102 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
103- - uses: actions/checkout@v6103+ - uses: actions/checkout@v7
104 - name: Set up pinned Go toolchain104 - name: Set up pinned Go toolchain
105 uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c105 uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c
106 with:106 with:
107 go-version-file: go.mod107 go-version-file: go.mod
108 cache: false108 cache: false
109 - name: Cache Go modules109 - name: Cache Go modules
110- uses: actions/cache@v5110+ uses: actions/cache@v6
111 with:111 with:
112 path: ~/go/pkg/mod112 path: ~/go/pkg/mod
113 key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}113 key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
114 restore-keys: |114 restore-keys: |
115 ${{ runner.os }}-go-mod-115 ${{ runner.os }}-go-mod-
116 - name: Cache Go build cache116 - name: Cache Go build cache
117- uses: actions/cache@v5117+ uses: actions/cache@v6
118 with:118 with:
119 path: ~/.cache/go-build119 path: ~/.cache/go-build
120 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:
2121 image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-48
2222 options: --privileged
2323 steps:
24- - uses: actions/checkout@v6
24+ - uses: actions/checkout@v7
2525
2626 - name: Get version
2727 id: version
@@ -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
M .github/workflows/release.yml +1 -1
@@ -13,7 +13,7 @@ jobs:
1313 release:
1414 runs-on: ubuntu-latest
1515 steps:
16- - uses: actions/checkout@v6
16+ - uses: actions/checkout@v7
1717 with:
1818 fetch-depth: 0
1919
@@ -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 cmd/systemviews/bootstrap_test.go +1 -1
@@ -157,7 +157,7 @@ func (f *bridgeServiceRecorder) TimelineByDomain(context.Context, string, int, i
157157 return f.historyEntries, nil
158158 }
159159
160-func (f *bridgeServiceRecorder) TimelineWindow(_ context.Context, before time.Time, _ string) (*entity.HistoryWindow, error) {
160+func (f *bridgeServiceRecorder) TimelineWindow(_ context.Context, before time.Time, _ int64, _ string) (*entity.HistoryWindow, error) {
161161 f.calledHistory.Store(true)
162162 return &entity.HistoryWindow{Entries: f.historyEntries, Before: before, After: before.Add(-24 * time.Hour)}, nil
163163 }
@@ -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 (
1616 github.com/charmbracelet/lipgloss v1.1.0
1717 github.com/fsnotify/fsnotify v1.10.1
1818 github.com/godbus/dbus/v5 v5.2.2
19- github.com/pelletier/go-toml/v2 v2.4.0
19+ github.com/pelletier/go-toml/v2 v2.4.2
2020 github.com/pressly/goose/v3 v3.27.1
2121 github.com/rs/zerolog v1.35.1
2222 github.com/spf13/cobra v1.10.2
2323 github.com/spf13/viper v1.21.0
2424 github.com/stretchr/testify v1.11.1
25- golang.org/x/image v0.42.0
25+ golang.org/x/image v0.43.0
2626 golang.org/x/net v0.56.0
2727 golang.org/x/sys v0.46.0
2828 gopkg.in/yaml.v3 v3.0.1
@@ -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
@@ -102,8 +102,8 @@ github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0
102102 github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
103103 github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
104104 github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
105-github.com/pelletier/go-toml/v2 v2.4.0 h1:Mwu0mAkUKbittDs3/ADDWXqMmq3EOK2VHiuCkV00Row=
106-github.com/pelletier/go-toml/v2 v2.4.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
105+github.com/pelletier/go-toml/v2 v2.4.2 h1:M2fKKbmyvI+hGId/D0W64qDBMVhJnNR10O5gIbMc//Q=
106+github.com/pelletier/go-toml/v2 v2.4.2/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
107107 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
108108 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
109109 github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
@@ -151,8 +151,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
151151 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
152152 golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw=
153153 golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
154-golang.org/x/image v0.42.0 h1:1gSs6ehNWXLbkHBIPcWztk3D/6aIA/8hauiAYtlodVY=
155-golang.org/x/image v0.42.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
154+golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
155+golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
156156 golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
157157 golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
158158 golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
@@ -102,8 +102,8 @@ github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0
102 github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=102 github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
103 github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=103 github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
104 github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=104 github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
105-github.com/pelletier/go-toml/v2 v2.4.0 h1:Mwu0mAkUKbittDs3/ADDWXqMmq3EOK2VHiuCkV00Row=105+github.com/pelletier/go-toml/v2 v2.4.2 h1:M2fKKbmyvI+hGId/D0W64qDBMVhJnNR10O5gIbMc//Q=
106-github.com/pelletier/go-toml/v2 v2.4.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=106+github.com/pelletier/go-toml/v2 v2.4.2/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
107 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=107 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
108 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=108 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
109 github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=109 github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
@@ -151,8 +151,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
151 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=151 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
152 golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw=152 golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw=
153 golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=153 golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
154-golang.org/x/image v0.42.0 h1:1gSs6ehNWXLbkHBIPcWztk3D/6aIA/8hauiAYtlodVY=154+golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
155-golang.org/x/image v0.42.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=155+golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
156 golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=156 golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
157 golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=157 golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
158 golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=158 golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
M internal/application/port/homepage.go +1 -1
@@ -12,7 +12,7 @@ import (
1212 type HomepageHistory interface {
1313 GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
1414 GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error)
15- GetRecentWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error)
15+ GetRecentWindow(ctx context.Context, before time.Time, beforeID int64, domain string) (*entity.HistoryWindow, error)
1616 Search(ctx context.Context, input dto.HistorySearchInput) (*dto.HistorySearchOutput, error)
1717 Delete(ctx context.Context, id int64) error
1818 ClearRange(ctx context.Context, rangeID string) error
@@ -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
M internal/application/port/mocks/homepage.go +20 -14
@@ -604,8 +604,8 @@ func (_c *MockHomepageHistory_GetRecentByDomain_Call) RunAndReturn(run func(ctx
604604 }
605605
606606 // GetRecentWindow provides a mock function for the type MockHomepageHistory
607-func (_mock *MockHomepageHistory) GetRecentWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) {
608- ret := _mock.Called(ctx, before, domain)
607+func (_mock *MockHomepageHistory) GetRecentWindow(ctx context.Context, before time.Time, beforeID int64, domain string) (*entity.HistoryWindow, error) {
608+ ret := _mock.Called(ctx, before, beforeID, domain)
609609
610610 if len(ret) == 0 {
611611 panic("no return value specified for GetRecentWindow")
@@ -613,18 +613,18 @@ func (_mock *MockHomepageHistory) GetRecentWindow(ctx context.Context, before ti
613613
614614 var r0 *entity.HistoryWindow
615615 var r1 error
616- if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, string) (*entity.HistoryWindow, error)); ok {
617- return returnFunc(ctx, before, domain)
616+ if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, int64, string) (*entity.HistoryWindow, error)); ok {
617+ return returnFunc(ctx, before, beforeID, domain)
618618 }
619- if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, string) *entity.HistoryWindow); ok {
620- r0 = returnFunc(ctx, before, domain)
619+ if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, int64, string) *entity.HistoryWindow); ok {
620+ r0 = returnFunc(ctx, before, beforeID, domain)
621621 } else {
622622 if ret.Get(0) != nil {
623623 r0 = ret.Get(0).(*entity.HistoryWindow)
624624 }
625625 }
626- if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time, string) error); ok {
627- r1 = returnFunc(ctx, before, domain)
626+ if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time, int64, string) error); ok {
627+ r1 = returnFunc(ctx, before, beforeID, domain)
628628 } else {
629629 r1 = ret.Error(1)
630630 }
@@ -639,12 +639,13 @@ type MockHomepageHistory_GetRecentWindow_Call struct {
639639 // GetRecentWindow is a helper method to define mock.On call
640640 // - ctx context.Context
641641 // - before time.Time
642+// - beforeID int64
642643 // - domain string
643-func (_e *MockHomepageHistory_Expecter) GetRecentWindow(ctx any, before any, domain any) *MockHomepageHistory_GetRecentWindow_Call {
644- return &MockHomepageHistory_GetRecentWindow_Call{Call: _e.mock.On("GetRecentWindow", ctx, before, domain)}
644+func (_e *MockHomepageHistory_Expecter) GetRecentWindow(ctx any, before any, beforeID any, domain any) *MockHomepageHistory_GetRecentWindow_Call {
645+ return &MockHomepageHistory_GetRecentWindow_Call{Call: _e.mock.On("GetRecentWindow", ctx, before, beforeID, domain)}
645646 }
646647
647-func (_c *MockHomepageHistory_GetRecentWindow_Call) Run(run func(ctx context.Context, before time.Time, domain string)) *MockHomepageHistory_GetRecentWindow_Call {
648+func (_c *MockHomepageHistory_GetRecentWindow_Call) Run(run func(ctx context.Context, before time.Time, beforeID int64, domain string)) *MockHomepageHistory_GetRecentWindow_Call {
648649 _c.Call.Run(func(args mock.Arguments) {
649650 var arg0 context.Context
650651 if args[0] != nil {
@@ -654,14 +655,19 @@ func (_c *MockHomepageHistory_GetRecentWindow_Call) Run(run func(ctx context.Con
654655 if args[1] != nil {
655656 arg1 = args[1].(time.Time)
656657 }
657- var arg2 string
658+ var arg2 int64
658659 if args[2] != nil {
659- arg2 = args[2].(string)
660+ arg2 = args[2].(int64)
661+ }
662+ var arg3 string
663+ if args[3] != nil {
664+ arg3 = args[3].(string)
660665 }
661666 run(
662667 arg0,
663668 arg1,
664669 arg2,
670+ arg3,
665671 )
666672 })
667673 return _c
@@ -672,7 +678,7 @@ func (_c *MockHomepageHistory_GetRecentWindow_Call) Return(historyWindow *entity
672678 return _c
673679 }
674680
675-func (_c *MockHomepageHistory_GetRecentWindow_Call) RunAndReturn(run func(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error)) *MockHomepageHistory_GetRecentWindow_Call {
681+func (_c *MockHomepageHistory_GetRecentWindow_Call) RunAndReturn(run func(ctx context.Context, before time.Time, beforeID int64, domain string) (*entity.HistoryWindow, error)) *MockHomepageHistory_GetRecentWindow_Call {
676682 _c.Call.Return(run)
677683 return _c
678684 }
@@ -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 (
1212 type SystemviewHistoryService interface {
1313 Timeline(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
1414 TimelineByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error)
15- TimelineWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error)
15+ TimelineWindow(ctx context.Context, before time.Time, beforeID int64, domain string) (*entity.HistoryWindow, error)
1616 Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error)
1717 DeleteEntry(ctx context.Context, id int64) error
1818 DeleteRange(ctx context.Context, rangeID string) error
@@ -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
M internal/application/usecase/search_history.go +40 -16
@@ -22,6 +22,7 @@ var _ port.HistorySidebarHistory = (*SearchHistoryUseCase)(nil)
2222
2323 const (
2424 historyWindowDuration = 24 * time.Hour
25+ historyWindowRowLimit = 100
2526
2627 defaultHistoryPageLimit = 50
2728 maxHistoryPageLimit = 500
@@ -115,17 +116,30 @@ func canonicalHistoryDomain(domain string) (string, error) {
115116 return domain, nil
116117 }
117118
118-// GetRecentWindow retrieves the 24-hour history window ending before the given cursor.
119-func (uc *SearchHistoryUseCase) GetRecentWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) {
120- if before.IsZero() {
119+// GetRecentWindow retrieves a row-bounded history window ending before the given cursor.
120+func (uc *SearchHistoryUseCase) GetRecentWindow(
121+ ctx context.Context,
122+ before time.Time,
123+ beforeID int64,
124+ domain string,
125+) (*entity.HistoryWindow, error) {
126+ if beforeID < 0 {
127+ return nil, fmt.Errorf("beforeID must be non-negative")
128+ }
129+ hasBefore := !before.IsZero()
130+ hasBeforeID := beforeID != 0
131+ if hasBefore != hasBeforeID {
132+ return nil, fmt.Errorf("before and beforeID must be provided together")
133+ }
134+ if !hasBefore {
121135 before = time.Now()
122136 }
123137 before = before.UTC()
124138 after := before.Add(-historyWindowDuration)
139+ queryLimit := historyWindowRowLimit + 1
125140
126141 var (
127142 entries []*entity.HistoryEntry
128- hasMore bool
129143 err error
130144 )
131145 originalDomain := domain
@@ -134,25 +148,35 @@ func (uc *SearchHistoryUseCase) GetRecentWindow(ctx context.Context, before time
134148 return nil, fmt.Errorf("domain is required")
135149 }
136150 if domain != "" {
137- entries, err = uc.historyRepo.GetRecentWindowByDomain(ctx, domain, before, after)
138- if err == nil {
139- hasMore, err = uc.historyRepo.HasEntriesByDomainBefore(ctx, domain, after)
140- }
151+ entries, err = uc.historyRepo.GetRecentWindowByDomain(ctx, domain, before, beforeID, queryLimit)
141152 } else {
142- entries, err = uc.historyRepo.GetRecentWindow(ctx, before, after)
143- if err == nil {
144- hasMore, err = uc.historyRepo.HasEntriesBefore(ctx, after)
145- }
153+ entries, err = uc.historyRepo.GetRecentWindow(ctx, before, beforeID, queryLimit)
146154 }
147155 if err != nil {
148156 return nil, fmt.Errorf("failed to get recent history window: %w", err)
149157 }
150158
159+ hasMore := len(entries) > historyWindowRowLimit
160+ if hasMore {
161+ entries = entries[:historyWindowRowLimit]
162+ }
163+ var cursorLastVisited time.Time
164+ var cursorID int64
165+ if len(entries) > 0 {
166+ last := entries[len(entries)-1]
167+ if last != nil {
168+ cursorLastVisited = last.LastVisited.UTC()
169+ cursorID = last.ID
170+ }
171+ }
172+
151173 return &entity.HistoryWindow{
152- Entries: entries,
153- Before: before,
154- After: after,
155- HasMore: hasMore,
174+ Entries: entries,
175+ Before: before,
176+ After: after,
177+ CursorLastVisited: cursorLastVisited,
178+ CursorID: cursorID,
179+ HasMore: hasMore,
156180 }, nil
157181 }
158182
@@ -22,6 +22,7 @@ var _ port.HistorySidebarHistory = (*SearchHistoryUseCase)(nil)
22 22
23 const (23 const (
24 historyWindowDuration = 24 * time.Hour24 historyWindowDuration = 24 * time.Hour
25+ historyWindowRowLimit = 100
25 26
26 defaultHistoryPageLimit = 5027 defaultHistoryPageLimit = 50
27 maxHistoryPageLimit = 50028 maxHistoryPageLimit = 500
@@ -115,17 +116,30 @@ func canonicalHistoryDomain(domain string) (string, error) {
115 return domain, nil116 return domain, nil
116 }117 }
117 118
118-// GetRecentWindow retrieves the 24-hour history window ending before the given cursor.119+// 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) {120+func (uc *SearchHistoryUseCase) GetRecentWindow(
120- if before.IsZero() {121+ ctx context.Context,
122+ before time.Time,
123+ beforeID int64,
124+ domain string,
125+) (*entity.HistoryWindow, error) {
126+ if beforeID < 0 {
127+ return nil, fmt.Errorf("beforeID must be non-negative")
128+ }
129+ hasBefore := !before.IsZero()
130+ hasBeforeID := beforeID != 0
131+ if hasBefore != hasBeforeID {
132+ return nil, fmt.Errorf("before and beforeID must be provided together")
133+ }
134+ if !hasBefore {
121 before = time.Now()135 before = time.Now()
122 }136 }
123 before = before.UTC()137 before = before.UTC()
124 after := before.Add(-historyWindowDuration)138 after := before.Add(-historyWindowDuration)
139+ queryLimit := historyWindowRowLimit + 1
125 140
126 var (141 var (
127 entries []*entity.HistoryEntry142 entries []*entity.HistoryEntry
128- hasMore bool
129 err error143 err error
130 )144 )
131 originalDomain := domain145 originalDomain := domain
@@ -134,25 +148,35 @@ func (uc *SearchHistoryUseCase) GetRecentWindow(ctx context.Context, before time
134 return nil, fmt.Errorf("domain is required")148 return nil, fmt.Errorf("domain is required")
135 }149 }
136 if domain != "" {150 if domain != "" {
137- entries, err = uc.historyRepo.GetRecentWindowByDomain(ctx, domain, before, after)151+ 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 {152 } else {
142- entries, err = uc.historyRepo.GetRecentWindow(ctx, before, after)153+ entries, err = uc.historyRepo.GetRecentWindow(ctx, before, beforeID, queryLimit)
143- if err == nil {
144- hasMore, err = uc.historyRepo.HasEntriesBefore(ctx, after)
145- }
146 }154 }
147 if err != nil {155 if err != nil {
148 return nil, fmt.Errorf("failed to get recent history window: %w", err)156 return nil, fmt.Errorf("failed to get recent history window: %w", err)
149 }157 }
150 158
159+ hasMore := len(entries) > historyWindowRowLimit
160+ if hasMore {
161+ entries = entries[:historyWindowRowLimit]
162+ }
163+ var cursorLastVisited time.Time
164+ var cursorID int64
165+ if len(entries) > 0 {
166+ last := entries[len(entries)-1]
167+ if last != nil {
168+ cursorLastVisited = last.LastVisited.UTC()
169+ cursorID = last.ID
170+ }
171+ }
172+
151 return &entity.HistoryWindow{173 return &entity.HistoryWindow{
152- Entries: entries,174+ Entries: entries,
153- Before: before,175+ Before: before,
154- After: after,176+ After: after,
155- HasMore: hasMore,177+ CursorLastVisited: cursorLastVisited,
178+ CursorID: cursorID,
179+ HasMore: hasMore,
156 }, nil180 }, nil
157 }181 }
158 182
M internal/application/usecase/search_history_test.go +44 -1
@@ -68,13 +68,56 @@ func TestSearchHistoryUseCase_GetRecentWindow_InvalidDomainReturnsValidationErro
6868 historyRepo := repomocks.NewMockHistoryRepository(t)
6969 uc := usecase.NewSearchHistoryUseCase(historyRepo)
7070
71- result, err := uc.GetRecentWindow(ctx, time.Now(), "///")
71+ result, err := uc.GetRecentWindow(ctx, time.Time{}, 0, "///")
7272
7373 require.Error(t, err)
7474 assert.Nil(t, result)
7575 assert.Contains(t, err.Error(), "domain is required")
7676 }
7777
78+func TestSearchHistoryUseCase_GetRecentWindow_RejectsInvalidCursorPairs(t *testing.T) {
79+ ctx := testContext()
80+ historyRepo := repomocks.NewMockHistoryRepository(t)
81+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
82+
83+ result, err := uc.GetRecentWindow(ctx, time.Time{}, 42, "")
84+ require.Error(t, err)
85+ assert.Nil(t, result)
86+ assert.Contains(t, err.Error(), "before and beforeID")
87+
88+ result, err = uc.GetRecentWindow(ctx, time.Now(), 0, "")
89+ require.Error(t, err)
90+ assert.Nil(t, result)
91+ assert.Contains(t, err.Error(), "before and beforeID")
92+
93+ result, err = uc.GetRecentWindow(ctx, time.Now(), -1, "")
94+ require.Error(t, err)
95+ assert.Nil(t, result)
96+ assert.Contains(t, err.Error(), "beforeID must be non-negative")
97+}
98+
99+func TestSearchHistoryUseCase_GetRecentWindow_UsesLimitPlusOneForHasMoreAndCursor(t *testing.T) {
100+ ctx := testContext()
101+ before := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
102+ entries := make([]*entity.HistoryEntry, 101)
103+ for i := range entries {
104+ entries[i] = &entity.HistoryEntry{ID: int64(200 - i), URL: "https://example.com", LastVisited: before.Add(-time.Duration(i) * time.Minute)}
105+ }
106+ historyRepo := repomocks.NewMockHistoryRepository(t)
107+ historyRepo.EXPECT().GetRecentWindow(mock.Anything, before, int64(999), 101).Return(entries, nil).Once()
108+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
109+
110+ result, err := uc.GetRecentWindow(ctx, before, 999, "")
111+
112+ require.NoError(t, err)
113+ require.NotNil(t, result)
114+ assert.Len(t, result.Entries, 100)
115+ assert.True(t, result.HasMore)
116+ assert.Equal(t, entries[99].LastVisited.UTC(), result.CursorLastVisited)
117+ assert.Equal(t, entries[99].ID, result.CursorID)
118+ assert.Equal(t, before.Add(-24*time.Hour), result.After)
119+}
120+
78121 func TestSearchHistoryUseCase_GetRecentSince_ReturnsEntries(t *testing.T) {
79122 ctx := testContext()
80123
@@ -68,13 +68,56 @@ func TestSearchHistoryUseCase_GetRecentWindow_InvalidDomainReturnsValidationErro
68 historyRepo := repomocks.NewMockHistoryRepository(t)68 historyRepo := repomocks.NewMockHistoryRepository(t)
69 uc := usecase.NewSearchHistoryUseCase(historyRepo)69 uc := usecase.NewSearchHistoryUseCase(historyRepo)
70 70
71- result, err := uc.GetRecentWindow(ctx, time.Now(), "///")71+ result, err := uc.GetRecentWindow(ctx, time.Time{}, 0, "///")
72 72
73 require.Error(t, err)73 require.Error(t, err)
74 assert.Nil(t, result)74 assert.Nil(t, result)
75 assert.Contains(t, err.Error(), "domain is required")75 assert.Contains(t, err.Error(), "domain is required")
76 }76 }
77 77
78+func TestSearchHistoryUseCase_GetRecentWindow_RejectsInvalidCursorPairs(t *testing.T) {
79+ ctx := testContext()
80+ historyRepo := repomocks.NewMockHistoryRepository(t)
81+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
82+
83+ result, err := uc.GetRecentWindow(ctx, time.Time{}, 42, "")
84+ require.Error(t, err)
85+ assert.Nil(t, result)
86+ assert.Contains(t, err.Error(), "before and beforeID")
87+
88+ result, err = uc.GetRecentWindow(ctx, time.Now(), 0, "")
89+ require.Error(t, err)
90+ assert.Nil(t, result)
91+ assert.Contains(t, err.Error(), "before and beforeID")
92+
93+ result, err = uc.GetRecentWindow(ctx, time.Now(), -1, "")
94+ require.Error(t, err)
95+ assert.Nil(t, result)
96+ assert.Contains(t, err.Error(), "beforeID must be non-negative")
97+}
98+
99+func TestSearchHistoryUseCase_GetRecentWindow_UsesLimitPlusOneForHasMoreAndCursor(t *testing.T) {
100+ ctx := testContext()
101+ before := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
102+ entries := make([]*entity.HistoryEntry, 101)
103+ for i := range entries {
104+ entries[i] = &entity.HistoryEntry{ID: int64(200 - i), URL: "https://example.com", LastVisited: before.Add(-time.Duration(i) * time.Minute)}
105+ }
106+ historyRepo := repomocks.NewMockHistoryRepository(t)
107+ historyRepo.EXPECT().GetRecentWindow(mock.Anything, before, int64(999), 101).Return(entries, nil).Once()
108+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
109+
110+ result, err := uc.GetRecentWindow(ctx, before, 999, "")
111+
112+ require.NoError(t, err)
113+ require.NotNil(t, result)
114+ assert.Len(t, result.Entries, 100)
115+ assert.True(t, result.HasMore)
116+ assert.Equal(t, entries[99].LastVisited.UTC(), result.CursorLastVisited)
117+ assert.Equal(t, entries[99].ID, result.CursorID)
118+ assert.Equal(t, before.Add(-24*time.Hour), result.After)
119+}
120+
78 func TestSearchHistoryUseCase_GetRecentSince_ReturnsEntries(t *testing.T) {121 func TestSearchHistoryUseCase_GetRecentSince_ReturnsEntries(t *testing.T) {
79 ctx := testContext()122 ctx := testContext()
80 123
M internal/domain/entity/history_window.go +7 -5
@@ -2,10 +2,12 @@ package entity
22
33 import "time"
44
5-// HistoryWindow contains one fixed-duration slice of browsing history.
5+// HistoryWindow contains one bounded slice of browsing history plus display window metadata.
66 type HistoryWindow struct {
7- Entries []*HistoryEntry `json:"entries"`
8- Before time.Time `json:"before"`
9- After time.Time `json:"after"`
10- HasMore bool `json:"has_more"`
7+ Entries []*HistoryEntry `json:"entries"`
8+ Before time.Time `json:"before"`
9+ After time.Time `json:"after"`
10+ CursorLastVisited time.Time `json:"cursor_last_visited"`
11+ CursorID int64 `json:"cursor_id,omitempty"`
12+ HasMore bool `json:"has_more"`
1113 }
@@ -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 {
2424 // GetRecentByDomain retrieves recent history entries for a domain with pagination. A zero limit means all matching entries.
2525 GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error)
2626
27- // GetRecentWindow retrieves entries visited in [after, before), sorted by recency.
28- GetRecentWindow(ctx context.Context, before, after time.Time) ([]*entity.HistoryEntry, error)
27+ // GetRecentWindow retrieves entries older than the stable (last_visited, id) cursor, sorted by recency.
28+ GetRecentWindow(ctx context.Context, before time.Time, beforeID int64, limit int) ([]*entity.HistoryEntry, error)
2929
30- // GetRecentWindowByDomain retrieves domain entries visited in [after, before), sorted by recency.
31- GetRecentWindowByDomain(ctx context.Context, domain string, before, after time.Time) ([]*entity.HistoryEntry, error)
32-
33- // HasEntriesBefore reports whether any history entry was visited before the given time.
34- HasEntriesBefore(ctx context.Context, before time.Time) (bool, error)
35-
36- // HasEntriesByDomainBefore reports whether any domain entry was visited before the given time.
37- HasEntriesByDomainBefore(ctx context.Context, domain string, before time.Time) (bool, error)
30+ // GetRecentWindowByDomain retrieves domain entries older than the stable (last_visited, id) cursor, sorted by recency.
31+ GetRecentWindowByDomain(ctx context.Context, domain string, before time.Time, beforeID int64, limit int) ([]*entity.HistoryEntry, error)
3832
3933 // GetRecentSince retrieves history entries visited within the last N days.
4034 // days must be > 0.
@@ -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
999999 }
10001000
10011001 // GetRecentWindow provides a mock function for the type MockHistoryRepository
1002-func (_mock *MockHistoryRepository) GetRecentWindow(ctx context.Context, before time.Time, after time.Time) ([]*entity.HistoryEntry, error) {
1003- ret := _mock.Called(ctx, before, after)
1002+func (_mock *MockHistoryRepository) GetRecentWindow(ctx context.Context, before time.Time, beforeID int64, limit int) ([]*entity.HistoryEntry, error) {
1003+ ret := _mock.Called(ctx, before, beforeID, limit)
10041004
10051005 if len(ret) == 0 {
10061006 panic("no return value specified for GetRecentWindow")
@@ -1008,18 +1008,18 @@ func (_mock *MockHistoryRepository) GetRecentWindow(ctx context.Context, before
10081008
10091009 var r0 []*entity.HistoryEntry
10101010 var r1 error
1011- if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, time.Time) ([]*entity.HistoryEntry, error)); ok {
1012- return returnFunc(ctx, before, after)
1011+ if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, int64, int) ([]*entity.HistoryEntry, error)); ok {
1012+ return returnFunc(ctx, before, beforeID, limit)
10131013 }
1014- if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, time.Time) []*entity.HistoryEntry); ok {
1015- r0 = returnFunc(ctx, before, after)
1014+ if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, int64, int) []*entity.HistoryEntry); ok {
1015+ r0 = returnFunc(ctx, before, beforeID, limit)
10161016 } else {
10171017 if ret.Get(0) != nil {
10181018 r0 = ret.Get(0).([]*entity.HistoryEntry)
10191019 }
10201020 }
1021- if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time, time.Time) error); ok {
1022- r1 = returnFunc(ctx, before, after)
1021+ if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time, int64, int) error); ok {
1022+ r1 = returnFunc(ctx, before, beforeID, limit)
10231023 } else {
10241024 r1 = ret.Error(1)
10251025 }
@@ -1034,12 +1034,13 @@ type MockHistoryRepository_GetRecentWindow_Call struct {
10341034 // GetRecentWindow is a helper method to define mock.On call
10351035 // - ctx context.Context
10361036 // - before time.Time
1037-// - after time.Time
1038-func (_e *MockHistoryRepository_Expecter) GetRecentWindow(ctx any, before any, after any) *MockHistoryRepository_GetRecentWindow_Call {
1039- return &MockHistoryRepository_GetRecentWindow_Call{Call: _e.mock.On("GetRecentWindow", ctx, before, after)}
1037+// - beforeID int64
1038+// - limit int
1039+func (_e *MockHistoryRepository_Expecter) GetRecentWindow(ctx any, before any, beforeID any, limit any) *MockHistoryRepository_GetRecentWindow_Call {
1040+ return &MockHistoryRepository_GetRecentWindow_Call{Call: _e.mock.On("GetRecentWindow", ctx, before, beforeID, limit)}
10401041 }
10411042
1042-func (_c *MockHistoryRepository_GetRecentWindow_Call) Run(run func(ctx context.Context, before time.Time, after time.Time)) *MockHistoryRepository_GetRecentWindow_Call {
1043+func (_c *MockHistoryRepository_GetRecentWindow_Call) Run(run func(ctx context.Context, before time.Time, beforeID int64, limit int)) *MockHistoryRepository_GetRecentWindow_Call {
10431044 _c.Call.Run(func(args mock.Arguments) {
10441045 var arg0 context.Context
10451046 if args[0] != nil {
@@ -1049,14 +1050,19 @@ func (_c *MockHistoryRepository_GetRecentWindow_Call) Run(run func(ctx context.C
10491050 if args[1] != nil {
10501051 arg1 = args[1].(time.Time)
10511052 }
1052- var arg2 time.Time
1053+ var arg2 int64
10531054 if args[2] != nil {
1054- arg2 = args[2].(time.Time)
1055+ arg2 = args[2].(int64)
1056+ }
1057+ var arg3 int
1058+ if args[3] != nil {
1059+ arg3 = args[3].(int)
10551060 }
10561061 run(
10571062 arg0,
10581063 arg1,
10591064 arg2,
1065+ arg3,
10601066 )
10611067 })
10621068 return _c
@@ -1067,14 +1073,14 @@ func (_c *MockHistoryRepository_GetRecentWindow_Call) Return(historyEntrys []*en
10671073 return _c
10681074 }
10691075
1070-func (_c *MockHistoryRepository_GetRecentWindow_Call) RunAndReturn(run func(ctx context.Context, before time.Time, after time.Time) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecentWindow_Call {
1076+func (_c *MockHistoryRepository_GetRecentWindow_Call) RunAndReturn(run func(ctx context.Context, before time.Time, beforeID int64, limit int) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecentWindow_Call {
10711077 _c.Call.Return(run)
10721078 return _c
10731079 }
10741080
10751081 // GetRecentWindowByDomain provides a mock function for the type MockHistoryRepository
1076-func (_mock *MockHistoryRepository) GetRecentWindowByDomain(ctx context.Context, domain string, before time.Time, after time.Time) ([]*entity.HistoryEntry, error) {
1077- ret := _mock.Called(ctx, domain, before, after)
1082+func (_mock *MockHistoryRepository) GetRecentWindowByDomain(ctx context.Context, domain string, before time.Time, beforeID int64, limit int) ([]*entity.HistoryEntry, error) {
1083+ ret := _mock.Called(ctx, domain, before, beforeID, limit)
10781084
10791085 if len(ret) == 0 {
10801086 panic("no return value specified for GetRecentWindowByDomain")
@@ -1082,18 +1088,18 @@ func (_mock *MockHistoryRepository) GetRecentWindowByDomain(ctx context.Context,
10821088
10831089 var r0 []*entity.HistoryEntry
10841090 var r1 error
1085- if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time, time.Time) ([]*entity.HistoryEntry, error)); ok {
1086- return returnFunc(ctx, domain, before, after)
1091+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time, int64, int) ([]*entity.HistoryEntry, error)); ok {
1092+ return returnFunc(ctx, domain, before, beforeID, limit)
10871093 }
1088- if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time, time.Time) []*entity.HistoryEntry); ok {
1089- r0 = returnFunc(ctx, domain, before, after)
1094+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time, int64, int) []*entity.HistoryEntry); ok {
1095+ r0 = returnFunc(ctx, domain, before, beforeID, limit)
10901096 } else {
10911097 if ret.Get(0) != nil {
10921098 r0 = ret.Get(0).([]*entity.HistoryEntry)
10931099 }
10941100 }
1095- if returnFunc, ok := ret.Get(1).(func(context.Context, string, time.Time, time.Time) error); ok {
1096- r1 = returnFunc(ctx, domain, before, after)
1101+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, time.Time, int64, int) error); ok {
1102+ r1 = returnFunc(ctx, domain, before, beforeID, limit)
10971103 } else {
10981104 r1 = ret.Error(1)
10991105 }
@@ -1109,12 +1115,13 @@ type MockHistoryRepository_GetRecentWindowByDomain_Call struct {
11091115 // - ctx context.Context
11101116 // - domain string
11111117 // - before time.Time
1112-// - after time.Time
1113-func (_e *MockHistoryRepository_Expecter) GetRecentWindowByDomain(ctx any, domain any, before any, after any) *MockHistoryRepository_GetRecentWindowByDomain_Call {
1114- return &MockHistoryRepository_GetRecentWindowByDomain_Call{Call: _e.mock.On("GetRecentWindowByDomain", ctx, domain, before, after)}
1118+// - beforeID int64
1119+// - limit int
1120+func (_e *MockHistoryRepository_Expecter) GetRecentWindowByDomain(ctx any, domain any, before any, beforeID any, limit any) *MockHistoryRepository_GetRecentWindowByDomain_Call {
1121+ return &MockHistoryRepository_GetRecentWindowByDomain_Call{Call: _e.mock.On("GetRecentWindowByDomain", ctx, domain, before, beforeID, limit)}
11151122 }
11161123
1117-func (_c *MockHistoryRepository_GetRecentWindowByDomain_Call) Run(run func(ctx context.Context, domain string, before time.Time, after time.Time)) *MockHistoryRepository_GetRecentWindowByDomain_Call {
1124+func (_c *MockHistoryRepository_GetRecentWindowByDomain_Call) Run(run func(ctx context.Context, domain string, before time.Time, beforeID int64, limit int)) *MockHistoryRepository_GetRecentWindowByDomain_Call {
11181125 _c.Call.Run(func(args mock.Arguments) {
11191126 var arg0 context.Context
11201127 if args[0] != nil {
@@ -1128,15 +1135,20 @@ func (_c *MockHistoryRepository_GetRecentWindowByDomain_Call) Run(run func(ctx c
11281135 if args[2] != nil {
11291136 arg2 = args[2].(time.Time)
11301137 }
1131- var arg3 time.Time
1138+ var arg3 int64
11321139 if args[3] != nil {
1133- arg3 = args[3].(time.Time)
1140+ arg3 = args[3].(int64)
1141+ }
1142+ var arg4 int
1143+ if args[4] != nil {
1144+ arg4 = args[4].(int)
11341145 }
11351146 run(
11361147 arg0,
11371148 arg1,
11381149 arg2,
11391150 arg3,
1151+ arg4,
11401152 )
11411153 })
11421154 return _c
@@ -1147,7 +1159,7 @@ func (_c *MockHistoryRepository_GetRecentWindowByDomain_Call) Return(historyEntr
11471159 return _c
11481160 }
11491161
1150-func (_c *MockHistoryRepository_GetRecentWindowByDomain_Call) RunAndReturn(run func(ctx context.Context, domain string, before time.Time, after time.Time) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecentWindowByDomain_Call {
1162+func (_c *MockHistoryRepository_GetRecentWindowByDomain_Call) RunAndReturn(run func(ctx context.Context, domain string, before time.Time, beforeID int64, limit int) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecentWindowByDomain_Call {
11511163 _c.Call.Return(run)
11521164 return _c
11531165 }
@@ -1214,144 +1226,6 @@ func (_c *MockHistoryRepository_GetStats_Call) RunAndReturn(run func(ctx context
12141226 return _c
12151227 }
12161228
1217-// HasEntriesBefore provides a mock function for the type MockHistoryRepository
1218-func (_mock *MockHistoryRepository) HasEntriesBefore(ctx context.Context, before time.Time) (bool, error) {
1219- ret := _mock.Called(ctx, before)
1220-
1221- if len(ret) == 0 {
1222- panic("no return value specified for HasEntriesBefore")
1223- }
1224-
1225- var r0 bool
1226- var r1 error
1227- if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) (bool, error)); ok {
1228- return returnFunc(ctx, before)
1229- }
1230- if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) bool); ok {
1231- r0 = returnFunc(ctx, before)
1232- } else {
1233- r0 = ret.Get(0).(bool)
1234- }
1235- if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time) error); ok {
1236- r1 = returnFunc(ctx, before)
1237- } else {
1238- r1 = ret.Error(1)
1239- }
1240- return r0, r1
1241-}
1242-
1243-// MockHistoryRepository_HasEntriesBefore_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasEntriesBefore'
1244-type MockHistoryRepository_HasEntriesBefore_Call struct {
1245- *mock.Call
1246-}
1247-
1248-// HasEntriesBefore is a helper method to define mock.On call
1249-// - ctx context.Context
1250-// - before time.Time
1251-func (_e *MockHistoryRepository_Expecter) HasEntriesBefore(ctx any, before any) *MockHistoryRepository_HasEntriesBefore_Call {
1252- return &MockHistoryRepository_HasEntriesBefore_Call{Call: _e.mock.On("HasEntriesBefore", ctx, before)}
1253-}
1254-
1255-func (_c *MockHistoryRepository_HasEntriesBefore_Call) Run(run func(ctx context.Context, before time.Time)) *MockHistoryRepository_HasEntriesBefore_Call {
1256- _c.Call.Run(func(args mock.Arguments) {
1257- var arg0 context.Context
1258- if args[0] != nil {
1259- arg0 = args[0].(context.Context)
1260- }
1261- var arg1 time.Time
1262- if args[1] != nil {
1263- arg1 = args[1].(time.Time)
1264- }
1265- run(
1266- arg0,
1267- arg1,
1268- )
1269- })
1270- return _c
1271-}
1272-
1273-func (_c *MockHistoryRepository_HasEntriesBefore_Call) Return(b bool, err error) *MockHistoryRepository_HasEntriesBefore_Call {
1274- _c.Call.Return(b, err)
1275- return _c
1276-}
1277-
1278-func (_c *MockHistoryRepository_HasEntriesBefore_Call) RunAndReturn(run func(ctx context.Context, before time.Time) (bool, error)) *MockHistoryRepository_HasEntriesBefore_Call {
1279- _c.Call.Return(run)
1280- return _c
1281-}
1282-
1283-// HasEntriesByDomainBefore provides a mock function for the type MockHistoryRepository
1284-func (_mock *MockHistoryRepository) HasEntriesByDomainBefore(ctx context.Context, domain string, before time.Time) (bool, error) {
1285- ret := _mock.Called(ctx, domain, before)
1286-
1287- if len(ret) == 0 {
1288- panic("no return value specified for HasEntriesByDomainBefore")
1289- }
1290-
1291- var r0 bool
1292- var r1 error
1293- if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time) (bool, error)); ok {
1294- return returnFunc(ctx, domain, before)
1295- }
1296- if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time) bool); ok {
1297- r0 = returnFunc(ctx, domain, before)
1298- } else {
1299- r0 = ret.Get(0).(bool)
1300- }
1301- if returnFunc, ok := ret.Get(1).(func(context.Context, string, time.Time) error); ok {
1302- r1 = returnFunc(ctx, domain, before)
1303- } else {
1304- r1 = ret.Error(1)
1305- }
1306- return r0, r1
1307-}
1308-
1309-// MockHistoryRepository_HasEntriesByDomainBefore_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasEntriesByDomainBefore'
1310-type MockHistoryRepository_HasEntriesByDomainBefore_Call struct {
1311- *mock.Call
1312-}
1313-
1314-// HasEntriesByDomainBefore is a helper method to define mock.On call
1315-// - ctx context.Context
1316-// - domain string
1317-// - before time.Time
1318-func (_e *MockHistoryRepository_Expecter) HasEntriesByDomainBefore(ctx any, domain any, before any) *MockHistoryRepository_HasEntriesByDomainBefore_Call {
1319- return &MockHistoryRepository_HasEntriesByDomainBefore_Call{Call: _e.mock.On("HasEntriesByDomainBefore", ctx, domain, before)}
1320-}
1321-
1322-func (_c *MockHistoryRepository_HasEntriesByDomainBefore_Call) Run(run func(ctx context.Context, domain string, before time.Time)) *MockHistoryRepository_HasEntriesByDomainBefore_Call {
1323- _c.Call.Run(func(args mock.Arguments) {
1324- var arg0 context.Context
1325- if args[0] != nil {
1326- arg0 = args[0].(context.Context)
1327- }
1328- var arg1 string
1329- if args[1] != nil {
1330- arg1 = args[1].(string)
1331- }
1332- var arg2 time.Time
1333- if args[2] != nil {
1334- arg2 = args[2].(time.Time)
1335- }
1336- run(
1337- arg0,
1338- arg1,
1339- arg2,
1340- )
1341- })
1342- return _c
1343-}
1344-
1345-func (_c *MockHistoryRepository_HasEntriesByDomainBefore_Call) Return(b bool, err error) *MockHistoryRepository_HasEntriesByDomainBefore_Call {
1346- _c.Call.Return(b, err)
1347- return _c
1348-}
1349-
1350-func (_c *MockHistoryRepository_HasEntriesByDomainBefore_Call) RunAndReturn(run func(ctx context.Context, domain string, before time.Time) (bool, error)) *MockHistoryRepository_HasEntriesByDomainBefore_Call {
1351- _c.Call.Return(run)
1352- return _c
1353-}
1354-
13551229 // IncrementVisitCount provides a mock function for the type MockHistoryRepository
13561230 func (_mock *MockHistoryRepository) IncrementVisitCount(ctx context.Context, url string) error {
13571231 ret := _mock.Called(ctx, url)
@@ -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/handlers/homepage/history.go +11 -2
@@ -40,6 +40,7 @@ type timelineByDomainRequest struct {
4040 type timelineWindowRequest struct {
4141 RequestID string `json:"requestId"`
4242 Before string `json:"before"`
43+ BeforeID int64 `json:"beforeId"`
4344 Domain string `json:"domain"`
4445 }
4546
@@ -124,7 +125,15 @@ func (h *HistoryHandlers) HandleTimelineWindow() port.WebUIMessageHandler {
124125 domain := strings.TrimSpace(req.Domain)
125126
126127 var before time.Time
127- if strings.TrimSpace(req.Before) != "" {
128+ hasBefore := strings.TrimSpace(req.Before) != ""
129+ if req.BeforeID < 0 {
130+ return NewErrorResponse(req.RequestID, fmt.Errorf("history window cursor id must be non-negative")), nil
131+ }
132+ hasBeforeID := req.BeforeID > 0
133+ if hasBefore != hasBeforeID {
134+ return NewErrorResponse(req.RequestID, fmt.Errorf("history window cursor requires before and beforeId")), nil
135+ }
136+ if hasBefore {
128137 parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(req.Before))
129138 if err != nil {
130139 return NewErrorResponse(req.RequestID, fmt.Errorf("invalid history window cursor")), nil
@@ -138,7 +147,7 @@ func (h *HistoryHandlers) HandleTimelineWindow() port.WebUIMessageHandler {
138147 Time("before", before).
139148 Msg("handling history_timeline_window")
140149
141- window, err := h.historyUC.GetRecentWindow(ctx, before, domain)
150+ window, err := h.historyUC.GetRecentWindow(ctx, before, req.BeforeID, domain)
142151 if err != nil {
143152 return NewErrorResponse(req.RequestID, err), nil
144153 }
@@ -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+}
@@ -0,0 +1,61 @@
1+package homepage
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
11+ "github.com/bnema/dumber/internal/domain/entity"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestHandleTimelineWindowRejectsIncompleteCursor(t *testing.T) {
17+ t.Parallel()
18+
19+ tests := map[string]json.RawMessage{
20+ "before without beforeId": json.RawMessage(`{"requestId":"req-1","before":"2026-04-25T09:00:00Z"}`),
21+ "beforeId without before": json.RawMessage(`{"requestId":"req-1","beforeId":42}`),
22+ "negative beforeId without before": json.RawMessage(`{"requestId":"req-1","beforeId":-1}`),
23+ }
24+ for name, payload := range tests {
25+ t.Run(name, func(t *testing.T) {
26+ t.Parallel()
27+
28+ history := portmocks.NewMockHomepageHistory(t)
29+ handler := NewHistoryHandlers(history).HandleTimelineWindow()
30+
31+ got, err := handler.Handle(context.Background(), port.WebViewID(0), payload)
32+ require.NoError(t, err)
33+
34+ resp, ok := got.(Response)
35+ require.True(t, ok)
36+ require.False(t, resp.Success)
37+ require.Contains(t, resp.Error, "cursor")
38+ })
39+ }
40+}
41+
42+func TestHandleTimelineWindowPassesCompleteCursor(t *testing.T) {
43+ t.Parallel()
44+
45+ before := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC)
46+ history := portmocks.NewMockHomepageHistory(t)
47+ history.EXPECT().
48+ GetRecentWindow(mock.Anything, before, int64(42), "example.com").
49+ Return(&entity.HistoryWindow{Before: before, CursorID: 42}, nil).
50+ Once()
51+
52+ handler := NewHistoryHandlers(history).HandleTimelineWindow()
53+ payload := json.RawMessage(`{"requestId":"req-2","before":"2026-04-25T09:00:00Z","beforeId":42,"domain":"example.com"}`)
54+
55+ got, err := handler.Handle(context.Background(), port.WebViewID(0), payload)
56+ require.NoError(t, err)
57+
58+ resp, ok := got.(Response)
59+ require.True(t, ok)
60+ require.True(t, resp.Success)
61+}
M internal/infrastructure/persistence/sqlite/history_repo.go +21 -31
@@ -524,10 +524,14 @@ func (r *historyRepo) GetRecentByDomain(ctx context.Context, domain string, limi
524524 return historyEntriesFromRows(rows), nil
525525 }
526526
527-func (r *historyRepo) GetRecentWindow(ctx context.Context, before, after time.Time) ([]*entity.HistoryEntry, error) {
527+func (r *historyRepo) GetRecentWindow(ctx context.Context, before time.Time, beforeID int64, limit int) ([]*entity.HistoryEntry, error) {
528+ if limit <= 0 {
529+ return []*entity.HistoryEntry{}, nil
530+ }
528531 rows, err := r.queries.GetRecentHistoryWindow(ctx, sqlc.GetRecentHistoryWindowParams{
529- Before: sql.NullTime{Time: before, Valid: true},
530- After: sql.NullTime{Time: after, Valid: true},
532+ Before: sql.NullTime{Time: before, Valid: true},
533+ BeforeID: beforeID,
534+ Limit: int64(limit),
531535 })
532536 if err != nil {
533537 return nil, err
@@ -535,16 +539,26 @@ func (r *historyRepo) GetRecentWindow(ctx context.Context, before, after time.Ti
535539 return historyEntriesFromRows(rows), nil
536540 }
537541
538-func (r *historyRepo) GetRecentWindowByDomain(ctx context.Context, domain string, before, after time.Time) ([]*entity.HistoryEntry, error) {
542+func (r *historyRepo) GetRecentWindowByDomain(
543+ ctx context.Context,
544+ domain string,
545+ before time.Time,
546+ beforeID int64,
547+ limit int,
548+) ([]*entity.HistoryEntry, error) {
539549 rawDomain := domain
540550 domain = domainurl.CanonicalDomain(domain)
541551 if domain == "" {
542552 return nil, fmt.Errorf("history domain is required: %q", rawDomain)
543553 }
554+ if limit <= 0 {
555+ return []*entity.HistoryEntry{}, nil
556+ }
544557 rows, err := r.queries.GetRecentHistoryWindowByDomain(ctx, sqlc.GetRecentHistoryWindowByDomainParams{
545- Domain: sql.NullString{String: domain, Valid: true},
546- Before: sql.NullTime{Time: before, Valid: true},
547- After: sql.NullTime{Time: after, Valid: true},
558+ Domain: sql.NullString{String: domain, Valid: true},
559+ Before: sql.NullTime{Time: before, Valid: true},
560+ BeforeID: beforeID,
561+ Limit: int64(limit),
548562 })
549563 if err != nil {
550564 return nil, err
@@ -552,30 +566,6 @@ func (r *historyRepo) GetRecentWindowByDomain(ctx context.Context, domain string
552566 return historyEntriesFromRows(rows), nil
553567 }
554568
555-func (r *historyRepo) HasEntriesBefore(ctx context.Context, before time.Time) (bool, error) {
556- value, err := r.queries.HasHistoryBefore(ctx, sql.NullTime{Time: before, Valid: true})
557- if err != nil {
558- return false, err
559- }
560- return value != 0, nil
561-}
562-
563-func (r *historyRepo) HasEntriesByDomainBefore(ctx context.Context, domain string, before time.Time) (bool, error) {
564- rawDomain := domain
565- domain = domainurl.CanonicalDomain(domain)
566- if domain == "" {
567- return false, fmt.Errorf("history domain is required: %q", rawDomain)
568- }
569- value, err := r.queries.HasHistoryByDomainBefore(ctx, sqlc.HasHistoryByDomainBeforeParams{
570- Domain: sql.NullString{String: domain, Valid: true},
571- Before: sql.NullTime{Time: before, Valid: true},
572- })
573- if err != nil {
574- return false, err
575- }
576- return value != 0, nil
577-}
578-
579569 func (r *historyRepo) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
580570 if days <= 0 {
581571 return nil, fmt.Errorf("days must be positive, got %d", days)
@@ -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
22
33 import (
44 "context"
5+ "fmt"
56 "path/filepath"
67 "testing"
78 "time"
@@ -890,3 +891,49 @@ func TestHistoryRepository_DomainColumnPowersFilteringStatsAndDelete(t *testing.
890891 require.Len(t, remaining, 1)
891892 assert.Equal(t, "https://other.test", remaining[0].URL)
892893 }
894+func TestHistoryRepository_GetRecentWindow_StableCursorWithTimestampTies(t *testing.T) {
895+ ctx := historyTestCtx()
896+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
897+
898+ db, err := sqlite.NewConnection(ctx, dbPath)
899+ require.NoError(t, err)
900+ t.Cleanup(func() { _ = db.Close() })
901+
902+ repo := sqlite.NewHistoryRepository(db)
903+ tied := time.Date(2026, 6, 24, 10, 0, 0, 0, time.UTC)
904+ older := tied.Add(-time.Hour)
905+ for i := 1; i <= 5; i++ {
906+ _, err = db.ExecContext(ctx, `INSERT INTO history (url, title, domain, last_visited, created_at) VALUES (?, ?, ?, ?, ?)`,
907+ fmt.Sprintf("https://example.com/%d", i), fmt.Sprintf("Entry %d", i), "example.com", tied, tied)
908+ require.NoError(t, err)
909+ }
910+ _, err = db.ExecContext(ctx, `INSERT INTO history (url, title, domain, last_visited, created_at) VALUES (?, ?, ?, ?, ?)`,
911+ "https://example.com/older", "Older", "example.com", older, older)
912+ require.NoError(t, err)
913+
914+ page1, err := repo.GetRecentWindow(ctx, tied.Add(time.Second), 0, 3)
915+ require.NoError(t, err)
916+ require.Len(t, page1, 3)
917+ assert.Equal(t, []int64{5, 4, 3}, historyIDs(page1))
918+
919+ page2, err := repo.GetRecentWindow(ctx, page1[len(page1)-1].LastVisited, page1[len(page1)-1].ID, 3)
920+ require.NoError(t, err)
921+ require.Len(t, page2, 3)
922+ assert.Equal(t, []int64{2, 1, 6}, historyIDs(page2))
923+
924+ seen := map[int64]bool{}
925+ for _, entry := range append(page1, page2...) {
926+ if seen[entry.ID] {
927+ t.Fatalf("duplicate history id %d", entry.ID)
928+ }
929+ seen[entry.ID] = true
930+ }
931+}
932+
933+func historyIDs(entries []*entity.HistoryEntry) []int64 {
934+ ids := make([]int64, len(entries))
935+ for i, entry := range entries {
936+ ids[i] = entry.ID
937+ }
938+ return ids
939+}
@@ -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
156156 return r.repo.GetRecentByDomain(ctx, domain, limit, offset)
157157 }
158158
159-func (r *LazyHistoryRepository) GetRecentWindow(ctx context.Context, before, after time.Time) ([]*entity.HistoryEntry, error) {
159+func (r *LazyHistoryRepository) GetRecentWindow(
160+ ctx context.Context,
161+ before time.Time,
162+ beforeID int64,
163+ limit int,
164+) ([]*entity.HistoryEntry, error) {
160165 if err := r.init(ctx); err != nil {
161166 return nil, err
162167 }
163- return r.repo.GetRecentWindow(ctx, before, after)
168+ return r.repo.GetRecentWindow(ctx, before, beforeID, limit)
164169 }
165170
166171 func (r *LazyHistoryRepository) GetRecentWindowByDomain(
167172 ctx context.Context,
168173 domain string,
169174 before time.Time,
170- after time.Time,
175+ beforeID int64,
176+ limit int,
171177 ) ([]*entity.HistoryEntry, error) {
172178 if err := r.init(ctx); err != nil {
173179 return nil, err
174180 }
175- return r.repo.GetRecentWindowByDomain(ctx, domain, before, after)
176-}
177-
178-func (r *LazyHistoryRepository) HasEntriesBefore(ctx context.Context, before time.Time) (bool, error) {
179- if err := r.init(ctx); err != nil {
180- return false, err
181- }
182- return r.repo.HasEntriesBefore(ctx, before)
183-}
184-
185-func (r *LazyHistoryRepository) HasEntriesByDomainBefore(ctx context.Context, domain string, before time.Time) (bool, error) {
186- if err := r.init(ctx); err != nil {
187- return false, err
188- }
189- return r.repo.HasEntriesByDomainBefore(ctx, domain, before)
181+ return r.repo.GetRecentWindowByDomain(ctx, domain, before, beforeID, limit)
190182 }
191183
192184 func (r *LazyHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
@@ -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 @@
11 -- name: GetRecentHistory :many
2-SELECT * FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ?;
2+SELECT * FROM history
3+ORDER BY last_visited DESC, id DESC
4+LIMIT ? OFFSET ?;
35
46 -- name: GetRecentHistoryByDomain :many
57 SELECT * FROM history
68 WHERE domain = @domain
7-ORDER BY last_visited DESC
9+ORDER BY last_visited DESC, id DESC
810 LIMIT @limit OFFSET @offset;
911
1012 -- name: GetAllRecentHistoryByDomain :many
1113 SELECT * FROM history
1214 WHERE domain = @domain
13-ORDER BY last_visited DESC;
15+ORDER BY last_visited DESC, id DESC;
1416
1517 -- name: GetRecentHistoryWindow :many
1618 SELECT * FROM history
17-WHERE last_visited < @before AND last_visited >= @after
18-ORDER BY last_visited DESC;
19+WHERE last_visited < @before OR (last_visited = @before AND id < @before_id)
20+ORDER BY last_visited DESC, id DESC
21+LIMIT @limit;
1922
2023 -- name: GetRecentHistoryWindowByDomain :many
2124 SELECT * FROM history
22-WHERE domain = @domain AND last_visited < @before AND last_visited >= @after
23-ORDER BY last_visited DESC;
24-
25--- name: HasHistoryBefore :one
26-SELECT EXISTS(SELECT 1 FROM history WHERE last_visited < @before LIMIT 1);
27-
28--- name: HasHistoryByDomainBefore :one
29-SELECT EXISTS(SELECT 1 FROM history WHERE domain = @domain AND last_visited < @before LIMIT 1);
25+WHERE domain = @domain AND (last_visited < @before OR (last_visited = @before AND id < @before_id))
26+ORDER BY last_visited DESC, id DESC
27+LIMIT @limit;
3028
3129 -- name: GetHistoryByURL :one
3230 SELECT * FROM history WHERE url = ? LIMIT 1;
@@ -153,7 +151,7 @@ ORDER BY visit_count DESC, last_visited DESC;
153151
154152 -- name: GetAllRecentHistory :many
155153 SELECT * FROM history
156-ORDER BY last_visited DESC;
154+ORDER BY last_visited DESC, id DESC;
157155
158156 -- name: GetAllMostVisited :many
159157 SELECT * FROM history
@@ -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) {
109109
110110 const GetAllRecentHistory = `-- name: GetAllRecentHistory :many
111111 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
112-ORDER BY last_visited DESC
112+ORDER BY last_visited DESC, id DESC
113113 `
114114
115115 func (q *Queries) GetAllRecentHistory(ctx context.Context) ([]History, error) {
@@ -147,7 +147,7 @@ func (q *Queries) GetAllRecentHistory(ctx context.Context) ([]History, error) {
147147 const GetAllRecentHistoryByDomain = `-- name: GetAllRecentHistoryByDomain :many
148148 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
149149 WHERE domain = ?1
150-ORDER BY last_visited DESC
150+ORDER BY last_visited DESC, id DESC
151151 `
152152
153153 func (q *Queries) GetAllRecentHistoryByDomain(ctx context.Context, domain sql.NullString) ([]History, error) {
@@ -387,7 +387,9 @@ func (q *Queries) GetMostVisited(ctx context.Context, datetime interface{}) ([]H
387387 }
388388
389389 const GetRecentHistory = `-- name: GetRecentHistory :many
390-SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ?
390+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
391+ORDER BY last_visited DESC, id DESC
392+LIMIT ? OFFSET ?
391393 `
392394
393395 type GetRecentHistoryParams struct {
@@ -430,7 +432,7 @@ func (q *Queries) GetRecentHistory(ctx context.Context, arg GetRecentHistoryPara
430432 const GetRecentHistoryByDomain = `-- name: GetRecentHistoryByDomain :many
431433 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
432434 WHERE domain = ?1
433-ORDER BY last_visited DESC
435+ORDER BY last_visited DESC, id DESC
434436 LIMIT ?3 OFFSET ?2
435437 `
436438
@@ -512,17 +514,19 @@ func (q *Queries) GetRecentHistorySince(ctx context.Context, datetime interface{
512514
513515 const GetRecentHistoryWindow = `-- name: GetRecentHistoryWindow :many
514516 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
515-WHERE last_visited < ?1 AND last_visited >= ?2
516-ORDER BY last_visited DESC
517+WHERE last_visited < ?1 OR (last_visited = ?1 AND id < ?2)
518+ORDER BY last_visited DESC, id DESC
519+LIMIT ?3
517520 `
518521
519522 type GetRecentHistoryWindowParams struct {
520- Before sql.NullTime `json:"before"`
521- After sql.NullTime `json:"after"`
523+ Before sql.NullTime `json:"before"`
524+ BeforeID int64 `json:"before_id"`
525+ Limit int64 `json:"limit"`
522526 }
523527
524528 func (q *Queries) GetRecentHistoryWindow(ctx context.Context, arg GetRecentHistoryWindowParams) ([]History, error) {
525- rows, err := q.db.QueryContext(ctx, GetRecentHistoryWindow, arg.Before, arg.After)
529+ rows, err := q.db.QueryContext(ctx, GetRecentHistoryWindow, arg.Before, arg.BeforeID, arg.Limit)
526530 if err != nil {
527531 return nil, err
528532 }
@@ -555,18 +559,25 @@ func (q *Queries) GetRecentHistoryWindow(ctx context.Context, arg GetRecentHisto
555559
556560 const GetRecentHistoryWindowByDomain = `-- name: GetRecentHistoryWindowByDomain :many
557561 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
558-WHERE domain = ?1 AND last_visited < ?2 AND last_visited >= ?3
559-ORDER BY last_visited DESC
562+WHERE domain = ?1 AND (last_visited < ?2 OR (last_visited = ?2 AND id < ?3))
563+ORDER BY last_visited DESC, id DESC
564+LIMIT ?4
560565 `
561566
562567 type GetRecentHistoryWindowByDomainParams struct {
563- Domain sql.NullString `json:"domain"`
564- Before sql.NullTime `json:"before"`
565- After sql.NullTime `json:"after"`
568+ Domain sql.NullString `json:"domain"`
569+ Before sql.NullTime `json:"before"`
570+ BeforeID int64 `json:"before_id"`
571+ Limit int64 `json:"limit"`
566572 }
567573
568574 func (q *Queries) GetRecentHistoryWindowByDomain(ctx context.Context, arg GetRecentHistoryWindowByDomainParams) ([]History, error) {
569- rows, err := q.db.QueryContext(ctx, GetRecentHistoryWindowByDomain, arg.Domain, arg.Before, arg.After)
575+ rows, err := q.db.QueryContext(ctx, GetRecentHistoryWindowByDomain,
576+ arg.Domain,
577+ arg.Before,
578+ arg.BeforeID,
579+ arg.Limit,
580+ )
570581 if err != nil {
571582 return nil, err
572583 }
@@ -597,33 +608,6 @@ func (q *Queries) GetRecentHistoryWindowByDomain(ctx context.Context, arg GetRec
597608 return items, nil
598609 }
599610
600-const HasHistoryBefore = `-- name: HasHistoryBefore :one
601-SELECT EXISTS(SELECT 1 FROM history WHERE last_visited < ?1 LIMIT 1)
602-`
603-
604-func (q *Queries) HasHistoryBefore(ctx context.Context, before sql.NullTime) (int64, error) {
605- row := q.db.QueryRowContext(ctx, HasHistoryBefore, before)
606- var column_1 int64
607- err := row.Scan(&column_1)
608- return column_1, err
609-}
610-
611-const HasHistoryByDomainBefore = `-- name: HasHistoryByDomainBefore :one
612-SELECT EXISTS(SELECT 1 FROM history WHERE domain = ?1 AND last_visited < ?2 LIMIT 1)
613-`
614-
615-type HasHistoryByDomainBeforeParams struct {
616- Domain sql.NullString `json:"domain"`
617- Before sql.NullTime `json:"before"`
618-}
619-
620-func (q *Queries) HasHistoryByDomainBefore(ctx context.Context, arg HasHistoryByDomainBeforeParams) (int64, error) {
621- row := q.db.QueryRowContext(ctx, HasHistoryByDomainBefore, arg.Domain, arg.Before)
622- var column_1 int64
623- err := row.Scan(&column_1)
624- return column_1, err
625-}
626-
627611 const IncrementVisitCount = `-- name: IncrementVisitCount :exec
628612 UPDATE history
629613 SET visit_count = visit_count + 1, last_visited = CURRENT_TIMESTAMP
@@ -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 {
7272 GetTagsForFavorite(ctx context.Context, favoriteID int64) ([]FavoriteTag, error)
7373 GetTotalSessionStatesSize(ctx context.Context) (interface{}, error)
7474 GetZoomLevel(ctx context.Context, domain string) (ZoomLevel, error)
75- HasHistoryBefore(ctx context.Context, before sql.NullTime) (int64, error)
76- HasHistoryByDomainBefore(ctx context.Context, arg HasHistoryByDomainBeforeParams) (int64, error)
7775 IncrementVisitCount(ctx context.Context, url string) error
7876 IncrementVisitCountByDelta(ctx context.Context, arg IncrementVisitCountByDeltaParams) error
7977 InsertSession(ctx context.Context, arg InsertSessionParams) error
@@ -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
108108 }{RequestID: nextRequestID(), Domain: domain, Limit: limit, Offset: offset})
109109 }
110110
111-func (c *Client) TimelineWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) {
111+func (c *Client) TimelineWindow(ctx context.Context, before time.Time, beforeID int64, domain string) (*entity.HistoryWindow, error) {
112+ if beforeID < 0 {
113+ return nil, fmt.Errorf("history window cursor beforeID must be non-negative")
114+ }
115+ if before.IsZero() != (beforeID == 0) {
116+ return nil, fmt.Errorf("history window cursor requires before and beforeID")
117+ }
112118 beforeCursor := ""
113119 if !before.IsZero() {
114120 beforeCursor = before.Format(time.RFC3339Nano)
@@ -116,8 +122,9 @@ func (c *Client) TimelineWindow(ctx context.Context, before time.Time, domain st
116122 return request[*entity.HistoryWindow](c, ctx, "history_timeline_window", struct {
117123 RequestID string `json:"requestId"`
118124 Before string `json:"before,omitempty"`
125+ BeforeID int64 `json:"beforeId,omitempty"`
119126 Domain string `json:"domain,omitempty"`
120- }{RequestID: nextRequestID(), Before: beforeCursor, Domain: strings.TrimSpace(domain)})
127+ }{RequestID: nextRequestID(), Before: beforeCursor, BeforeID: beforeID, Domain: strings.TrimSpace(domain)})
121128 }
122129
123130 func (c *Client) Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error) {
@@ -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) {
135135 native := newTransportRecorder(t, true, []byte(`{"requestId":"req-window","success":true}`))
136136 client := NewClient(native, nil)
137137
138- _, err := client.TimelineWindow(context.Background(), time.Time{}, "")
138+ _, err := client.TimelineWindow(context.Background(), time.Time{}, 0, "")
139139 if err != nil {
140140 t.Fatalf("TimelineWindow() error = %v", err)
141141 }
@@ -152,7 +152,59 @@ func TestClientTimelineWindowOmitsZeroCursor(t *testing.T) {
152152 t.Fatalf("unmarshal payload: %v", err)
153153 }
154154 if _, ok := payload["before"]; ok {
155- t.Fatalf("zero TimelineWindow cursor should be omitted, payload = %s", msg.Payload)
155+ t.Fatalf("zero TimelineWindow cursor should be omit before, payload = %s", msg.Payload)
156+ }
157+ if _, ok := payload["beforeId"]; ok {
158+ t.Fatalf("zero TimelineWindow cursor should omit beforeId, payload = %s", msg.Payload)
159+ }
160+}
161+
162+func TestClientTimelineWindowSendsCompleteCursor(t *testing.T) {
163+ t.Parallel()
164+
165+ native := newTransportRecorder(t, true, []byte(`{"requestId":"req-window","success":true}`))
166+ client := NewClient(native, nil)
167+ before := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC)
168+
169+ _, err := client.TimelineWindow(context.Background(), before, 42, "example.com")
170+ if err != nil {
171+ t.Fatalf("TimelineWindow() error = %v", err)
172+ }
173+
174+ var msg port.WebUIMessage
175+ if err := json.Unmarshal(native.last, &msg); err != nil {
176+ t.Fatalf("unmarshal sent envelope: %v", err)
177+ }
178+ var payload map[string]any
179+ if err := json.Unmarshal(msg.Payload, &payload); err != nil {
180+ t.Fatalf("unmarshal payload: %v", err)
181+ }
182+ if payload["before"] != before.Format(time.RFC3339Nano) {
183+ t.Fatalf("before = %v, want %s", payload["before"], before.Format(time.RFC3339Nano))
184+ }
185+ if payload["beforeId"] != float64(42) {
186+ t.Fatalf("beforeId = %v, want 42", payload["beforeId"])
187+ }
188+}
189+
190+func TestClientTimelineWindowRejectsPartialCursor(t *testing.T) {
191+ t.Parallel()
192+
193+ native := newTransportRecorder(t, true, []byte(`{"requestId":"req-window","success":true}`))
194+ client := NewClient(native, nil)
195+ before := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC)
196+
197+ if _, err := client.TimelineWindow(context.Background(), before, 0, ""); err == nil {
198+ t.Fatal("TimelineWindow(before without beforeID) error = nil")
199+ }
200+ if _, err := client.TimelineWindow(context.Background(), time.Time{}, 42, ""); err == nil {
201+ t.Fatal("TimelineWindow(beforeID without before) error = nil")
202+ }
203+ if _, err := client.TimelineWindow(context.Background(), time.Time{}, -1, ""); err == nil {
204+ t.Fatal("TimelineWindow(negative beforeID without before) error = nil")
205+ }
206+ if native.called {
207+ t.Fatal("TimelineWindow sent request for invalid partial cursor")
156208 }
157209 }
158210
@@ -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/systemviews/app.go +22 -3
@@ -38,6 +38,7 @@ type App struct {
3838 historyOffset int
3939 historyWindowBefore time.Time
4040 historyWindowAfter time.Time
41+ historyCursorID int64
4142 historyHasMore bool
4243 historyNotice string
4344 historyError string
@@ -571,6 +572,7 @@ type historyRouteSnapshot struct {
571572 offset int
572573 windowBefore time.Time
573574 windowAfter time.Time
575+ cursorID int64
574576 notice string
575577 error string
576578 }
@@ -581,6 +583,7 @@ type historyRouteResult struct {
581583 domains []*entity.DomainStat
582584 windowBefore time.Time
583585 windowAfter time.Time
586+ cursorID int64
584587 hasMore bool
585588 html string
586589 }
@@ -595,6 +598,7 @@ func (a *App) currentHistoryRouteSnapshotLocked() historyRouteSnapshot {
595598 offset: a.historyOffset,
596599 windowBefore: a.historyWindowBefore,
597600 windowAfter: a.historyWindowAfter,
601+ cursorID: a.historyCursorID,
598602 notice: a.historyNotice,
599603 error: a.historyError,
600604 }
@@ -633,8 +637,7 @@ func (a *App) renderHistoryRouteSnapshot(ctx context.Context, snapshot historyRo
633637 }
634638 if window != nil {
635639 result.windowBefore = window.Before
636- result.windowAfter = window.After
637- result.hasMore = window.HasMore
640+ result.windowAfter, result.cursorID, result.hasMore = historyWindowCursor(window)
638641 }
639642 data := historyRenderData{
640643 Entries: entries,
@@ -662,6 +665,20 @@ func (a *App) renderHistoryRouteSnapshot(ctx context.Context, snapshot historyRo
662665 return result, nil
663666 }
664667
668+func historyWindowCursor(window *entity.HistoryWindow) (time.Time, int64, bool) {
669+ if window == nil || !window.HasMore {
670+ return time.Time{}, 0, false
671+ }
672+ cursorTime := window.CursorLastVisited
673+ if cursorTime.IsZero() {
674+ cursorTime = window.After
675+ }
676+ if cursorTime.IsZero() || window.CursorID <= 0 {
677+ return time.Time{}, 0, false
678+ }
679+ return cursorTime, window.CursorID, true
680+}
681+
665682 func loadHistoryWindowSnapshot(ctx context.Context, snapshot historyRouteSnapshot) (*entity.HistoryWindow, []*entity.HistoryEntry, error) {
666683 query := strings.TrimSpace(snapshot.query)
667684 domain := strings.TrimSpace(snapshot.domainFilter)
@@ -669,7 +686,7 @@ func loadHistoryWindowSnapshot(ctx context.Context, snapshot historyRouteSnapsho
669686 entries, err := snapshot.history.Search(ctx, query, historySearchLimit)
670687 return nil, entries, err
671688 }
672- window, err := snapshot.history.TimelineWindow(ctx, snapshot.windowBefore, domain)
689+ window, err := snapshot.history.TimelineWindow(ctx, snapshot.windowAfter, snapshot.cursorID, domain)
673690 if err != nil {
674691 return nil, nil, err
675692 }
@@ -689,6 +706,7 @@ func (a *App) commitHistoryRouteResultLocked(result historyRouteResult) {
689706 a.historyDomainStats = result.domains
690707 a.historyWindowBefore = result.windowBefore
691708 a.historyWindowAfter = result.windowAfter
709+ a.historyCursorID = result.cursorID
692710 a.historyHasMore = result.hasMore
693711 a.renderedHTML = result.html
694712 }
@@ -812,6 +830,7 @@ func (a *App) resetRouteState() {
812830 a.historyOffset = 0
813831 a.historyWindowBefore = time.Time{}
814832 a.historyWindowAfter = time.Time{}
833+ a.historyCursorID = 0
815834 a.historyHasMore = false
816835 a.historyNotice = ""
817836 a.historyError = ""
@@ -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) {
213213
214214 cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC)
215215 history := &recordingHistoryService{window: &entity.HistoryWindow{
216- Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example", Title: "Older entry", LastVisited: cursor.Add(-time.Hour)}},
217- Before: cursor,
218- After: cursor.Add(-24 * time.Hour),
219- HasMore: true,
216+ Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example", Title: "Older entry", LastVisited: cursor.Add(-time.Hour)}},
217+ Before: cursor,
218+ CursorLastVisited: cursor.Add(-time.Hour),
219+ CursorID: 2,
220+ HasMore: true,
220221 }}
221222 dom := &recordingActionDOM{}
222223 app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
223224 app.currentRoute = RouteHistory
224225 app.historyEntries = []*entity.HistoryEntry{{ID: 1, URL: "https://newer.example", Title: "Newer entry", LastVisited: cursor}}
225226 app.historyWindowAfter = cursor
227+ app.historyCursorID = 1
226228 app.historyHasMore = true
227229
228230 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
@@ -230,6 +232,9 @@ func TestAppHandleHistoryLoadMoreAppendsOlderWindow(t *testing.T) {
230232 Data: map[string]string{"before": cursor.Format(time.RFC3339Nano)},
231233 }))
232234 assert.Equal(t, cursor, history.windowBefore)
235+ assert.Equal(t, int64(1), history.windowBeforeID)
236+ assert.Equal(t, int64(2), app.historyCursorID)
237+ assert.Equal(t, cursor.Add(-time.Hour), app.historyWindowAfter)
233238 assert.Len(t, app.historyEntries, 2)
234239 assert.Contains(t, dom.AppendedHTML(), "Older entry")
235240 assert.Contains(t, dom.AppendedHTML(), `data-sv-action="history.loadMore"`)
@@ -241,16 +246,18 @@ func TestAppHandleHistoryLoadMoreRemountsWhenAppendUnavailable(t *testing.T) {
241246
242247 cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC)
243248 history := &recordingHistoryService{window: &entity.HistoryWindow{
244- Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example", Title: "Older entry", LastVisited: cursor.Add(-time.Hour)}},
245- Before: cursor,
246- After: cursor.Add(-24 * time.Hour),
247- HasMore: true,
249+ Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example", Title: "Older entry", LastVisited: cursor.Add(-time.Hour)}},
250+ Before: cursor,
251+ CursorLastVisited: cursor.Add(-time.Hour),
252+ CursorID: 2,
253+ HasMore: true,
248254 }}
249255 dom := &recordingDOM{}
250256 app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
251257 app.currentRoute = RouteHistory
252258 app.historyEntries = []*entity.HistoryEntry{{ID: 1, URL: "https://newer.example", Title: "Newer entry", LastVisited: cursor}}
253259 app.historyWindowAfter = cursor
260+ app.historyCursorID = 1
254261 app.historyHasMore = true
255262
256263 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
@@ -258,11 +265,40 @@ func TestAppHandleHistoryLoadMoreRemountsWhenAppendUnavailable(t *testing.T) {
258265 Data: map[string]string{"before": cursor.Format(time.RFC3339Nano)},
259266 }))
260267 assert.Equal(t, cursor, history.windowBefore)
268+ assert.Equal(t, int64(1), history.windowBeforeID)
261269 assert.Len(t, app.historyEntries, 2)
262270 assert.Contains(t, dom.HTML(), "Older entry")
263271 assert.Contains(t, dom.HTML(), `data-sv-action="history.loadMore"`)
264272 }
265273
274+func TestAppHandleHistoryLoadMoreDisablesPartialNextCursor(t *testing.T) {
275+ t.Parallel()
276+
277+ cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC)
278+ history := &recordingHistoryService{window: &entity.HistoryWindow{
279+ Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example", Title: "Older entry", LastVisited: cursor.Add(-time.Hour)}},
280+ Before: cursor,
281+ After: cursor.Add(-24 * time.Hour),
282+ HasMore: true,
283+ }}
284+ dom := &recordingActionDOM{}
285+ app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
286+ app.currentRoute = RouteHistory
287+ app.historyEntries = []*entity.HistoryEntry{{ID: 1, URL: "https://newer.example", Title: "Newer entry", LastVisited: cursor}}
288+ app.historyWindowAfter = cursor
289+ app.historyCursorID = 1
290+ app.historyHasMore = true
291+
292+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
293+ Action: historyActionLoadMore,
294+ Data: map[string]string{"before": cursor.Format(time.RFC3339Nano)},
295+ }))
296+ assert.False(t, app.historyHasMore)
297+ assert.Zero(t, app.historyCursorID)
298+ assert.True(t, app.historyWindowAfter.IsZero())
299+ assert.NotContains(t, dom.AppendedHTML(), `data-sv-action="history.loadMore"`)
300+}
301+
266302 func TestAppHandleHistoryLoadMoreIgnoresStaleCursor(t *testing.T) {
267303 t.Parallel()
268304
@@ -1072,6 +1108,7 @@ type recordingHistoryService struct {
10721108 statsCalled bool
10731109 window *entity.HistoryWindow
10741110 windowBefore time.Time
1111+ windowBeforeID int64
10751112 windowDomain string
10761113 analyticsCalled bool
10771114
@@ -1101,9 +1138,10 @@ func (s *recordingHistoryService) TimelineByDomain(_ context.Context, domain str
11011138 return s.entries, s.err
11021139 }
11031140
1104-func (s *recordingHistoryService) TimelineWindow(_ context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) {
1141+func (s *recordingHistoryService) TimelineWindow(_ context.Context, before time.Time, beforeID int64, domain string) (*entity.HistoryWindow, error) {
11051142 s.called = true
11061143 s.windowBefore = before
1144+ s.windowBeforeID = beforeID
11071145 s.windowDomain = domain
11081146 if s.timelineStarted != nil {
11091147 s.startOnce.Do(func() { close(s.timelineStarted) })
@@ -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 (
66 "strconv"
77 "strings"
88 "time"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/domain/entity"
912 )
1013
1114 const (
@@ -167,54 +170,84 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error {
167170 return nil
168171 }
169172
173+type historyLoadMoreRequest struct {
174+ before time.Time
175+ beforeID int64
176+ domain string
177+ history port.SystemviewHistoryService
178+ dom DOM
179+}
180+
170181 func (a *App) handleHistoryLoadMore(ctx context.Context, event DOMAction) error {
171182 a.lockState()
172- if a.deps.History == nil {
173- a.unlockState()
174- return fmt.Errorf("history service not configured")
183+ req, ok, err := a.historyLoadMoreRequestLocked(event)
184+ a.unlockState()
185+ if err != nil || !ok {
186+ return err
175187 }
176- if strings.TrimSpace(a.historyQuery) != "" || !a.historyHasMore {
188+
189+ window, err := req.history.TimelineWindow(ctx, req.before, req.beforeID, req.domain)
190+ if err != nil {
191+ return err
192+ }
193+ if window == nil {
194+ a.lockState()
195+ a.historyHasMore = false
177196 a.unlockState()
178197 return nil
179198 }
199+
200+ a.lockState()
201+ fragment, fallbackHTML := a.appendHistoryWindowLocked(window, req.dom)
202+ a.unlockState()
203+
204+ if appender, ok := req.dom.(DOMHistoryTimelineAppender); ok {
205+ return appender.AppendHistoryTimeline(fragment)
206+ }
207+ return a.mountHTML(fallbackHTML)
208+}
209+
210+func (a *App) historyLoadMoreRequestLocked(event DOMAction) (historyLoadMoreRequest, bool, error) {
211+ if a.deps.History == nil {
212+ return historyLoadMoreRequest{}, false, fmt.Errorf("history service not configured")
213+ }
214+ if strings.TrimSpace(a.historyQuery) != "" || !a.historyHasMore {
215+ return historyLoadMoreRequest{}, false, nil
216+ }
217+
180218 before := a.historyWindowAfter
219+ beforeID := a.historyCursorID
181220 if requested := strings.TrimSpace(event.Data["before"]); requested != "" {
182221 parsed, err := time.Parse(time.RFC3339Nano, requested)
183222 if err != nil {
184- a.unlockState()
185- return fmt.Errorf("invalid history cursor")
223+ return historyLoadMoreRequest{}, false, fmt.Errorf("invalid history cursor")
186224 }
187225 if !before.IsZero() && !parsed.Equal(before) {
188- a.unlockState()
189- return nil
226+ return historyLoadMoreRequest{}, false, nil
190227 }
191228 before = parsed
192229 }
193- if before.IsZero() {
194- before = time.Now()
195- }
196- domain := a.historyDomainFilter
197- history := a.deps.History
198- dom := a.deps.DOM
199- a.unlockState()
200-
201- window, err := history.TimelineWindow(ctx, before, domain)
202- if err != nil {
203- return err
204- }
205- if window == nil {
206- a.lockState()
230+ if before.IsZero() || beforeID <= 0 {
231+ a.historyWindowAfter = time.Time{}
232+ a.historyCursorID = 0
207233 a.historyHasMore = false
208- a.unlockState()
209- return nil
234+ return historyLoadMoreRequest{}, false, nil
210235 }
211236
212- a.lockState()
237+ return historyLoadMoreRequest{
238+ before: before,
239+ beforeID: beforeID,
240+ domain: a.historyDomainFilter,
241+ history: a.deps.History,
242+ dom: a.deps.DOM,
243+ }, true, nil
244+}
245+
246+func (a *App) appendHistoryWindowLocked(window *entity.HistoryWindow, dom DOM) (string, string) {
213247 appendSkipFirstDate := lastHistoryDateKey(a.historyEntries)
214248 a.historyEntries = append(a.historyEntries, window.Entries...)
215249 a.historyWindowBefore = window.Before
216- a.historyWindowAfter = window.After
217- a.historyHasMore = window.HasMore
250+ a.historyWindowAfter, a.historyCursorID, a.historyHasMore = historyWindowCursor(window)
218251 data := historyRenderData{
219252 Entries: window.Entries,
220253 Stats: a.historyStats,
@@ -231,32 +264,28 @@ func (a *App) handleHistoryLoadMore(ctx context.Context, event DOMAction) error
231264 Error: a.historyError,
232265 }
233266 fragment := historyTimelineAppendHTML(data)
234- var fallbackHTML string
235- if _, ok := dom.(DOMHistoryTimelineAppender); !ok {
236- fullData := data
237- fullData.Entries = a.historyEntries
238- title := historyDocumentTitle(fullData)
239- a.renderedHTML = renderAppFrame(renderedPage{
240- route: RouteHistory,
241- title: title,
242- subtitle: "History",
243- subtitleDetail: historyTitleDetail(fullData),
244- body: historyHTML(fullData),
245- }, a.shellTheme)
246- fallbackHTML = a.renderedHTML
267+ if _, ok := dom.(DOMHistoryTimelineAppender); ok {
268+ return fragment, ""
247269 }
248- a.unlockState()
249270
250- if appender, ok := dom.(DOMHistoryTimelineAppender); ok {
251- return appender.AppendHistoryTimeline(fragment)
252- }
253- return a.mountHTML(fallbackHTML)
271+ fullData := data
272+ fullData.Entries = a.historyEntries
273+ title := historyDocumentTitle(fullData)
274+ a.renderedHTML = renderAppFrame(renderedPage{
275+ route: RouteHistory,
276+ title: title,
277+ subtitle: "History",
278+ subtitleDetail: historyTitleDetail(fullData),
279+ body: historyHTML(fullData),
280+ }, a.shellTheme)
281+ return fragment, a.renderedHTML
254282 }
255283
256284 func (a *App) resetHistoryWindowState() {
257285 a.historyOffset = 0
258286 a.historyWindowBefore = time.Time{}
259287 a.historyWindowAfter = time.Time{}
288+ a.historyCursorID = 0
260289 a.historyHasMore = false
261290 }
262291
@@ -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

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