detached 5caec5d

fix(history): tighten sidebar regression coverage

commit
5caec5d7b78cb0dd5e45badc2dc9e9d3cd43c216
author
brice · · 2026-06-13 07:02 UTC
parent
b5b4cef
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +55 -18
M internal/ui/browser_window_test.go +11 -3
@@ -1225,6 +1225,7 @@ func TestApp_HistorySidebarToggleHandler_NilFocusedWindowIsNoOp(t *testing.T) {
12251225 // TestHistorySidebarConfig_OnCloseHidesSidebar verifies that the OnClose
12261226 // callback hides the sidebar for the owning browser window.
12271227 func TestHistorySidebarConfig_OnCloseHidesSidebar(t *testing.T) {
1228+ ctx := context.Background()
12281229 tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
12291230 bwTabs := entity.NewTabList()
12301231 bwTabs.Add(tab)
@@ -1238,10 +1239,17 @@ func TestHistorySidebarConfig_OnCloseHidesSidebar(t *testing.T) {
12381239 sidebarVisible: true,
12391240 }
12401241
1241- // Simulate the hide step used by OnClose's hideAndRestoreFocus closure.
1242- bw.hideHistorySidebar()
1243- assert.False(t, bw.sidebarVisible, "sidebar must be hidden by hideAndRestoreFocus")
1242+ app := &App{
1243+ tabs: entity.NewTabList(),
1244+ browserWindows: map[string]*browserWindow{bw.id: bw},
1245+ workspaceViews: map[entity.TabID]*component.WorkspaceView{tab.ID: {}},
1246+ }
1247+ app.tabs.Add(tab)
1248+
1249+ cfg := app.buildHistorySidebarConfig(ctx, bw)
1250+ cfg.OnClose()
12441251
1252+ assert.False(t, bw.sidebarVisible, "sidebar must be hidden by OnClose")
12451253 }
12461254
12471255 // =============================================================================
@@ -1225,6 +1225,7 @@ func TestApp_HistorySidebarToggleHandler_NilFocusedWindowIsNoOp(t *testing.T) {
1225 // TestHistorySidebarConfig_OnCloseHidesSidebar verifies that the OnClose1225 // TestHistorySidebarConfig_OnCloseHidesSidebar verifies that the OnClose
1226 // callback hides the sidebar for the owning browser window.1226 // callback hides the sidebar for the owning browser window.
1227 func TestHistorySidebarConfig_OnCloseHidesSidebar(t *testing.T) {1227 func TestHistorySidebarConfig_OnCloseHidesSidebar(t *testing.T) {
1228+ ctx := context.Background()
1228 tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))1229 tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
1229 bwTabs := entity.NewTabList()1230 bwTabs := entity.NewTabList()
1230 bwTabs.Add(tab)1231 bwTabs.Add(tab)
@@ -1238,10 +1239,17 @@ func TestHistorySidebarConfig_OnCloseHidesSidebar(t *testing.T) {
1238 sidebarVisible: true,1239 sidebarVisible: true,
1239 }1240 }
1240 1241
1241- // Simulate the hide step used by OnClose's hideAndRestoreFocus closure.1242+ app := &App{
1242- bw.hideHistorySidebar()1243+ tabs: entity.NewTabList(),
1243- assert.False(t, bw.sidebarVisible, "sidebar must be hidden by hideAndRestoreFocus")1244+ browserWindows: map[string]*browserWindow{bw.id: bw},
1245+ workspaceViews: map[entity.TabID]*component.WorkspaceView{tab.ID: {}},
1246+ }
1247+ app.tabs.Add(tab)
1248+
1249+ cfg := app.buildHistorySidebarConfig(ctx, bw)
1250+ cfg.OnClose()
1244 1251
1252+ assert.False(t, bw.sidebarVisible, "sidebar must be hidden by OnClose")
1245 }1253 }
1246 1254
1247 // =============================================================================1255 // =============================================================================
M internal/ui/component/history_sidebar.go +16 -3
@@ -95,6 +95,10 @@ type HistorySidebar struct {
9595 // Retained callbacks
9696 retainedCallbacks []interface{}
9797
98+ // idleScheduler dispatches work onto the GTK main thread.
99+ // Tests may override it to exercise scheduled callbacks deterministically.
100+ idleScheduler func(glib.SourceFunc)
101+
98102 // Context
99103 ctx context.Context
100104 cancel context.CancelFunc
@@ -804,7 +808,7 @@ func (hs *HistorySidebar) doFTSearch(query string, gen uint64) {
804808 }
805809 return false
806810 })
807- glib.IdleAdd(&cb, 0)
811+ hs.scheduleIdle(cb)
808812 }()
809813 }
810814
@@ -826,6 +830,14 @@ func (hs *HistorySidebar) applySearchResults(entries []*entity.HistoryEntry, gen
826830 return true
827831 }
828832
833+func (hs *HistorySidebar) scheduleIdle(cb glib.SourceFunc) {
834+ if hs != nil && hs.idleScheduler != nil {
835+ hs.idleScheduler(cb)
836+ return
837+ }
838+ glib.IdleAdd(&cb, 0)
839+}
840+
829841 // scheduleClearList clears the list box on the GTK main thread.
830842 func (hs *HistorySidebar) scheduleClearList() {
831843 cb := glib.SourceFunc(func(uintptr) bool {
@@ -839,7 +851,7 @@ func (hs *HistorySidebar) scheduleClearList() {
839851 listBox.RemoveAll()
840852 return false
841853 })
842- glib.IdleAdd(&cb, 0)
854+ hs.scheduleIdle(cb)
843855 }
844856
845857 // scheduleRebuild schedules a list rebuild on the GTK main thread.
@@ -848,7 +860,7 @@ func (hs *HistorySidebar) scheduleRebuild() {
848860 hs.rebuildList()
849861 return false
850862 })
851- glib.IdleAdd(&cb, 0)
863+ hs.scheduleIdle(cb)
852864 }
853865
854866 // =============================================================================
@@ -1240,6 +1252,7 @@ func (hs *HistorySidebar) handleDeleteKey() bool {
12401252 hs.prevSelectedURL = nextSelectedURL
12411253 hs.searchGen++
12421254 hs.removeFromAllEntries(url, entryID)
1255+ hs.totalLoaded = len(hs.allEntries)
12431256 hs.removeFromSearchResults(entryID)
12441257 hs.rebuildLocalGroups()
12451258 hs.mu.Unlock()
@@ -95,6 +95,10 @@ type HistorySidebar struct {
95 // Retained callbacks95 // Retained callbacks
96 retainedCallbacks []interface{}96 retainedCallbacks []interface{}
97 97
98+ // idleScheduler dispatches work onto the GTK main thread.
99+ // Tests may override it to exercise scheduled callbacks deterministically.
100+ idleScheduler func(glib.SourceFunc)
101+
98 // Context102 // Context
99 ctx context.Context103 ctx context.Context
100 cancel context.CancelFunc104 cancel context.CancelFunc
@@ -804,7 +808,7 @@ func (hs *HistorySidebar) doFTSearch(query string, gen uint64) {
804 }808 }
805 return false809 return false
806 })810 })
807- glib.IdleAdd(&cb, 0)811+ hs.scheduleIdle(cb)
808 }()812 }()
809 }813 }
810 814
@@ -826,6 +830,14 @@ func (hs *HistorySidebar) applySearchResults(entries []*entity.HistoryEntry, gen
826 return true830 return true
827 }831 }
828 832
833+func (hs *HistorySidebar) scheduleIdle(cb glib.SourceFunc) {
834+ if hs != nil && hs.idleScheduler != nil {
835+ hs.idleScheduler(cb)
836+ return
837+ }
838+ glib.IdleAdd(&cb, 0)
839+}
840+
829 // scheduleClearList clears the list box on the GTK main thread.841 // scheduleClearList clears the list box on the GTK main thread.
830 func (hs *HistorySidebar) scheduleClearList() {842 func (hs *HistorySidebar) scheduleClearList() {
831 cb := glib.SourceFunc(func(uintptr) bool {843 cb := glib.SourceFunc(func(uintptr) bool {
@@ -839,7 +851,7 @@ func (hs *HistorySidebar) scheduleClearList() {
839 listBox.RemoveAll()851 listBox.RemoveAll()
840 return false852 return false
841 })853 })
842- glib.IdleAdd(&cb, 0)854+ hs.scheduleIdle(cb)
843 }855 }
844 856
845 // scheduleRebuild schedules a list rebuild on the GTK main thread.857 // scheduleRebuild schedules a list rebuild on the GTK main thread.
@@ -848,7 +860,7 @@ func (hs *HistorySidebar) scheduleRebuild() {
848 hs.rebuildList()860 hs.rebuildList()
849 return false861 return false
850 })862 })
851- glib.IdleAdd(&cb, 0)863+ hs.scheduleIdle(cb)
852 }864 }
853 865
854 // =============================================================================866 // =============================================================================
@@ -1240,6 +1252,7 @@ func (hs *HistorySidebar) handleDeleteKey() bool {
1240 hs.prevSelectedURL = nextSelectedURL1252 hs.prevSelectedURL = nextSelectedURL
1241 hs.searchGen++1253 hs.searchGen++
1242 hs.removeFromAllEntries(url, entryID)1254 hs.removeFromAllEntries(url, entryID)
1255+ hs.totalLoaded = len(hs.allEntries)
1243 hs.removeFromSearchResults(entryID)1256 hs.removeFromSearchResults(entryID)
1244 hs.rebuildLocalGroups()1257 hs.rebuildLocalGroups()
1245 hs.mu.Unlock()1258 hs.mu.Unlock()
M internal/ui/component/history_sidebar_search_test.go +28 -12
@@ -9,6 +9,7 @@ import (
99
1010 "github.com/bnema/dumber/internal/application/dto"
1111 "github.com/bnema/dumber/internal/domain/entity"
12+ "github.com/bnema/puregotk/v4/glib"
1213 "github.com/rs/zerolog"
1314 "github.com/stretchr/testify/assert"
1415 "github.com/stretchr/testify/require"
@@ -151,6 +152,7 @@ func (f *fakeHistorySidebarHistory) Delete(ctx context.Context, id int64) error
151152
152153 func TestApplySearchResults_StaleGenerationDropsResultsAfterSearch(t *testing.T) {
153154 searchCalled := make(chan struct{}, 1)
155+ idleCalled := make(chan glib.SourceFunc, 1)
154156
155157 history := &fakeHistorySidebarHistory{
156158 searchFn: func(context.Context, dto.HistorySearchInput) (*dto.HistorySearchOutput, error) {
@@ -165,20 +167,29 @@ func TestApplySearchResults_StaleGenerationDropsResultsAfterSearch(t *testing.T)
165167 hs.ctx = context.Background()
166168 hs.historyUC = history
167169 hs.searchGen = 1
170+ hs.idleScheduler = func(cb glib.SourceFunc) {
171+ idleCalled <- cb
172+ }
168173
169- // Start search with gen=1
174+ // Start search with gen=1.
170175 hs.doFTSearch("test", 1)
171- <-searchCalled // Wait for the goroutine to pick up the search
176+ select {
177+ case <-searchCalled:
178+ case <-time.After(time.Second):
179+ t.Fatal("timed out waiting for search use case to be invoked")
180+ }
172181
173- // Advance gen before the UI callback would apply results.
182+ // Advance gen before the queued UI callback applies results.
174183 hs.mu.Lock()
175184 hs.searchGen = 2
176185 hs.mu.Unlock()
177186
178- applied := hs.applySearchResults([]*entity.HistoryEntry{{
179- ID: 1, URL: "https://result.com", Title: "Result", LastVisited: time.Now(),
180- }}, 1, nil)
181- assert.False(t, applied, "stale search results must be dropped")
187+ select {
188+ case cb := <-idleCalled:
189+ cb(0)
190+ case <-time.After(time.Second):
191+ t.Fatal("timed out waiting for scheduled idle callback")
192+ }
182193
183194 hs.mu.RLock()
184195 defer hs.mu.RUnlock()
@@ -189,6 +200,7 @@ func TestApplySearchResults_StaleGenerationDropsResultsAfterSearch(t *testing.T)
189200
190201 func TestApplySearchResults_CurrentGenerationAppliedAfterSearch(t *testing.T) {
191202 searchCalled := make(chan struct{}, 1)
203+ idleCalled := make(chan glib.SourceFunc, 1)
192204
193205 history := &fakeHistorySidebarHistory{
194206 searchFn: func(context.Context, dto.HistorySearchInput) (*dto.HistorySearchOutput, error) {
@@ -203,6 +215,9 @@ func TestApplySearchResults_CurrentGenerationAppliedAfterSearch(t *testing.T) {
203215 hs.ctx = context.Background()
204216 hs.historyUC = history
205217 hs.searchGen = 1
218+ hs.idleScheduler = func(cb glib.SourceFunc) {
219+ idleCalled <- cb
220+ }
206221
207222 // Start the search and wait for the use case to be invoked.
208223 hs.doFTSearch("live", 1)
@@ -212,11 +227,12 @@ func TestApplySearchResults_CurrentGenerationAppliedAfterSearch(t *testing.T) {
212227 t.Fatal("timed out waiting for search use case to be invoked")
213228 }
214229
215- // glib.IdleAdd is a no-op without GTK, so apply the callback effect directly.
216- applied := hs.applySearchResults([]*entity.HistoryEntry{
217- {ID: 1, URL: "https://live.com", Title: "Live", LastVisited: time.Now()},
218- }, 1, nil)
219- require.True(t, applied)
230+ select {
231+ case cb := <-idleCalled:
232+ cb(0)
233+ case <-time.After(time.Second):
234+ t.Fatal("timed out waiting for scheduled idle callback")
235+ }
220236
221237 hs.mu.RLock()
222238 assert.NotNil(t, hs.searchResults)
@@ -9,6 +9,7 @@ import (
9 9
10 "github.com/bnema/dumber/internal/application/dto"10 "github.com/bnema/dumber/internal/application/dto"
11 "github.com/bnema/dumber/internal/domain/entity"11 "github.com/bnema/dumber/internal/domain/entity"
12+ "github.com/bnema/puregotk/v4/glib"
12 "github.com/rs/zerolog"13 "github.com/rs/zerolog"
13 "github.com/stretchr/testify/assert"14 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"15 "github.com/stretchr/testify/require"
@@ -151,6 +152,7 @@ func (f *fakeHistorySidebarHistory) Delete(ctx context.Context, id int64) error
151 152
152 func TestApplySearchResults_StaleGenerationDropsResultsAfterSearch(t *testing.T) {153 func TestApplySearchResults_StaleGenerationDropsResultsAfterSearch(t *testing.T) {
153 searchCalled := make(chan struct{}, 1)154 searchCalled := make(chan struct{}, 1)
155+ idleCalled := make(chan glib.SourceFunc, 1)
154 156
155 history := &fakeHistorySidebarHistory{157 history := &fakeHistorySidebarHistory{
156 searchFn: func(context.Context, dto.HistorySearchInput) (*dto.HistorySearchOutput, error) {158 searchFn: func(context.Context, dto.HistorySearchInput) (*dto.HistorySearchOutput, error) {
@@ -165,20 +167,29 @@ func TestApplySearchResults_StaleGenerationDropsResultsAfterSearch(t *testing.T)
165 hs.ctx = context.Background()167 hs.ctx = context.Background()
166 hs.historyUC = history168 hs.historyUC = history
167 hs.searchGen = 1169 hs.searchGen = 1
170+ hs.idleScheduler = func(cb glib.SourceFunc) {
171+ idleCalled <- cb
172+ }
168 173
169- // Start search with gen=1174+ // Start search with gen=1.
170 hs.doFTSearch("test", 1)175 hs.doFTSearch("test", 1)
171- <-searchCalled // Wait for the goroutine to pick up the search176+ select {
177+ case <-searchCalled:
178+ case <-time.After(time.Second):
179+ t.Fatal("timed out waiting for search use case to be invoked")
180+ }
172 181
173- // Advance gen before the UI callback would apply results.182+ // Advance gen before the queued UI callback applies results.
174 hs.mu.Lock()183 hs.mu.Lock()
175 hs.searchGen = 2184 hs.searchGen = 2
176 hs.mu.Unlock()185 hs.mu.Unlock()
177 186
178- applied := hs.applySearchResults([]*entity.HistoryEntry{{187+ select {
179- ID: 1, URL: "https://result.com", Title: "Result", LastVisited: time.Now(),188+ case cb := <-idleCalled:
180- }}, 1, nil)189+ cb(0)
181- assert.False(t, applied, "stale search results must be dropped")190+ case <-time.After(time.Second):
191+ t.Fatal("timed out waiting for scheduled idle callback")
192+ }
182 193
183 hs.mu.RLock()194 hs.mu.RLock()
184 defer hs.mu.RUnlock()195 defer hs.mu.RUnlock()
@@ -189,6 +200,7 @@ func TestApplySearchResults_StaleGenerationDropsResultsAfterSearch(t *testing.T)
189 200
190 func TestApplySearchResults_CurrentGenerationAppliedAfterSearch(t *testing.T) {201 func TestApplySearchResults_CurrentGenerationAppliedAfterSearch(t *testing.T) {
191 searchCalled := make(chan struct{}, 1)202 searchCalled := make(chan struct{}, 1)
203+ idleCalled := make(chan glib.SourceFunc, 1)
192 204
193 history := &fakeHistorySidebarHistory{205 history := &fakeHistorySidebarHistory{
194 searchFn: func(context.Context, dto.HistorySearchInput) (*dto.HistorySearchOutput, error) {206 searchFn: func(context.Context, dto.HistorySearchInput) (*dto.HistorySearchOutput, error) {
@@ -203,6 +215,9 @@ func TestApplySearchResults_CurrentGenerationAppliedAfterSearch(t *testing.T) {
203 hs.ctx = context.Background()215 hs.ctx = context.Background()
204 hs.historyUC = history216 hs.historyUC = history
205 hs.searchGen = 1217 hs.searchGen = 1
218+ hs.idleScheduler = func(cb glib.SourceFunc) {
219+ idleCalled <- cb
220+ }
206 221
207 // Start the search and wait for the use case to be invoked.222 // Start the search and wait for the use case to be invoked.
208 hs.doFTSearch("live", 1)223 hs.doFTSearch("live", 1)
@@ -212,11 +227,12 @@ func TestApplySearchResults_CurrentGenerationAppliedAfterSearch(t *testing.T) {
212 t.Fatal("timed out waiting for search use case to be invoked")227 t.Fatal("timed out waiting for search use case to be invoked")
213 }228 }
214 229
215- // glib.IdleAdd is a no-op without GTK, so apply the callback effect directly.230+ select {
216- applied := hs.applySearchResults([]*entity.HistoryEntry{231+ case cb := <-idleCalled:
217- {ID: 1, URL: "https://live.com", Title: "Live", LastVisited: time.Now()},232+ cb(0)
218- }, 1, nil)233+ case <-time.After(time.Second):
219- require.True(t, applied)234+ t.Fatal("timed out waiting for scheduled idle callback")
235+ }
220 236
221 hs.mu.RLock()237 hs.mu.RLock()
222 assert.NotNil(t, hs.searchResults)238 assert.NotNil(t, hs.searchResults)

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