detached 9b42f5b

fix(history): address review findings

commit
9b42f5be9300660a320a6b982486a139040a493c
author
brice · · 2026-06-13 06:38 UTC
parent
9a9b787
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +139 -115
A internal/application/port/history_sidebar.go +16 -0
@@ -0,0 +1,16 @@
1+package port
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/dto"
7+ "github.com/bnema/dumber/internal/domain/entity"
8+)
9+
10+// HistorySidebarHistory provides the narrow history operations needed by the
11+// native GTK history sidebar.
12+type HistorySidebarHistory interface {
13+ GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
14+ Search(ctx context.Context, input dto.HistorySearchInput) (*dto.HistorySearchOutput, error)
15+ Delete(ctx context.Context, id int64) error
16+}
@@ -0,0 +1,16 @@
1+package port
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/dto"
7+ "github.com/bnema/dumber/internal/domain/entity"
8+)
9+
10+// HistorySidebarHistory provides the narrow history operations needed by the
11+// native GTK history sidebar.
12+type HistorySidebarHistory interface {
13+ GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
14+ Search(ctx context.Context, input dto.HistorySearchInput) (*dto.HistorySearchOutput, error)
15+ Delete(ctx context.Context, id int64) error
16+}
M internal/application/usecase/search_history.go +3 -1
@@ -15,8 +15,10 @@ import (
1515 "github.com/bnema/dumber/internal/logging"
1616 )
1717
18-// Compile-time check: SearchHistoryUseCase must satisfy port.HomepageHistory.
18+// Compile-time checks: SearchHistoryUseCase must satisfy the application
19+// history ports used by WebUI handlers and the native GTK history sidebar.
1920 var _ port.HomepageHistory = (*SearchHistoryUseCase)(nil)
21+var _ port.HistorySidebarHistory = (*SearchHistoryUseCase)(nil)
2022
2123 const (
2224 historyWindowDuration = 24 * time.Hour
@@ -15,8 +15,10 @@ import (
15 "github.com/bnema/dumber/internal/logging"15 "github.com/bnema/dumber/internal/logging"
16 )16 )
17 17
18-// Compile-time check: SearchHistoryUseCase must satisfy port.HomepageHistory.18+// Compile-time checks: SearchHistoryUseCase must satisfy the application
19+// history ports used by WebUI handlers and the native GTK history sidebar.
19 var _ port.HomepageHistory = (*SearchHistoryUseCase)(nil)20 var _ port.HomepageHistory = (*SearchHistoryUseCase)(nil)
21+var _ port.HistorySidebarHistory = (*SearchHistoryUseCase)(nil)
20 22
21 const (23 const (
22 historyWindowDuration = 24 * time.Hour24 historyWindowDuration = 24 * time.Hour
M internal/ui/app.go +1 -11
@@ -3522,17 +3522,7 @@ func (a *App) wireKeyboardActions() {
35223522 }
35233523 return a.EjectActivePaneToWindow(ctx, paneID)
35243524 })
3525- a.kbDispatcher.SetOnToggleHistorySidebar(func(ctx context.Context) error {
3526- bw := a.lastFocusedBrowserWindow()
3527- if bw == nil {
3528- return fmt.Errorf("history sidebar unavailable: no focused browser window")
3529- }
3530- if bw.historySidebar == nil {
3531- return fmt.Errorf("history sidebar unavailable: native sidebar not initialized")
3532- }
3533- bw.toggleHistorySidebar()
3534- return nil
3535- })
3525+ a.kbDispatcher.SetOnToggleHistorySidebar(a.toggleHistorySidebarAction)
35363526 a.kbDispatcher.SetOnToggleFloatingPane(func(ctx context.Context) error {
35373527 return a.ToggleFloatingPane(ctx)
35383528 })
@@ -3522,17 +3522,7 @@ func (a *App) wireKeyboardActions() {
3522 }3522 }
3523 return a.EjectActivePaneToWindow(ctx, paneID)3523 return a.EjectActivePaneToWindow(ctx, paneID)
3524 })3524 })
3525- a.kbDispatcher.SetOnToggleHistorySidebar(func(ctx context.Context) error {3525+ a.kbDispatcher.SetOnToggleHistorySidebar(a.toggleHistorySidebarAction)
3526- bw := a.lastFocusedBrowserWindow()
3527- if bw == nil {
3528- return fmt.Errorf("history sidebar unavailable: no focused browser window")
3529- }
3530- if bw.historySidebar == nil {
3531- return fmt.Errorf("history sidebar unavailable: native sidebar not initialized")
3532- }
3533- bw.toggleHistorySidebar()
3534- return nil
3535- })
3536 a.kbDispatcher.SetOnToggleFloatingPane(func(ctx context.Context) error {3526 a.kbDispatcher.SetOnToggleFloatingPane(func(ctx context.Context) error {
3537 return a.ToggleFloatingPane(ctx)3527 return a.ToggleFloatingPane(ctx)
3538 })3528 })
M internal/ui/browser_window.go +15 -3
@@ -301,7 +301,7 @@ func (bw *browserWindow) initHistorySidebar(ctx context.Context, a *App) {
301301 // buildHistorySidebarConfig constructs the HistorySidebarConfig for the given
302302 // browser window. Extracted from initHistorySidebar for testability.
303303 func (a *App) buildHistorySidebarConfig(ctx context.Context, bw *browserWindow) component.HistorySidebarConfig {
304- var historyUC port.HomepageHistory
304+ var historyUC port.HistorySidebarHistory
305305 if a.deps != nil {
306306 historyUC = a.deps.HistoryUC
307307 }
@@ -348,7 +348,7 @@ func (bw *browserWindow) toggleHistorySidebar(widthCfg ...window.SidebarWidthCon
348348 // showHistorySidebar makes the sidebar visible and grabs focus for the search
349349 // entry. An optional width config can be provided to override the default width.
350350 func (bw *browserWindow) showHistorySidebar(widthCfg ...window.SidebarWidthConfig) {
351- if bw == nil || bw.historySidebar == nil {
351+ if bw == nil || bw.historySidebar == nil || bw.mainWindow == nil {
352352 return
353353 }
354354 // Apply width config if provided
@@ -371,6 +371,18 @@ func historySidebarWidthConfig(widthPx int) window.SidebarWidthConfig {
371371 return cfg
372372 }
373373
374+func (a *App) toggleHistorySidebarAction(ctx context.Context) error {
375+ bw := a.lastFocusedBrowserWindow()
376+ if bw == nil {
377+ return fmt.Errorf("history sidebar unavailable: no focused browser window")
378+ }
379+ if bw.historySidebar == nil {
380+ return fmt.Errorf("history sidebar unavailable: native sidebar not initialized")
381+ }
382+ bw.toggleHistorySidebar()
383+ return nil
384+}
385+
374386 func (bw *browserWindow) applySidebarWidthConfig(a *App) {
375387 if bw == nil || bw.mainWindow == nil || a == nil || a.deps == nil || a.deps.Config == nil {
376388 return
@@ -381,7 +393,7 @@ func (bw *browserWindow) applySidebarWidthConfig(a *App) {
381393 // hideHistorySidebar hides the sidebar. Callers should also restore focus
382394 // to the active content pane after calling this.
383395 func (bw *browserWindow) hideHistorySidebar() {
384- if bw == nil || bw.historySidebar == nil {
396+ if bw == nil || bw.historySidebar == nil || bw.mainWindow == nil {
385397 return
386398 }
387399 bw.historySidebar.Hide()
@@ -301,7 +301,7 @@ func (bw *browserWindow) initHistorySidebar(ctx context.Context, a *App) {
301 // buildHistorySidebarConfig constructs the HistorySidebarConfig for the given301 // buildHistorySidebarConfig constructs the HistorySidebarConfig for the given
302 // browser window. Extracted from initHistorySidebar for testability.302 // browser window. Extracted from initHistorySidebar for testability.
303 func (a *App) buildHistorySidebarConfig(ctx context.Context, bw *browserWindow) component.HistorySidebarConfig {303 func (a *App) buildHistorySidebarConfig(ctx context.Context, bw *browserWindow) component.HistorySidebarConfig {
304- var historyUC port.HomepageHistory304+ var historyUC port.HistorySidebarHistory
305 if a.deps != nil {305 if a.deps != nil {
306 historyUC = a.deps.HistoryUC306 historyUC = a.deps.HistoryUC
307 }307 }
@@ -348,7 +348,7 @@ func (bw *browserWindow) toggleHistorySidebar(widthCfg ...window.SidebarWidthCon
348 // showHistorySidebar makes the sidebar visible and grabs focus for the search348 // showHistorySidebar makes the sidebar visible and grabs focus for the search
349 // entry. An optional width config can be provided to override the default width.349 // entry. An optional width config can be provided to override the default width.
350 func (bw *browserWindow) showHistorySidebar(widthCfg ...window.SidebarWidthConfig) {350 func (bw *browserWindow) showHistorySidebar(widthCfg ...window.SidebarWidthConfig) {
351- if bw == nil || bw.historySidebar == nil {351+ if bw == nil || bw.historySidebar == nil || bw.mainWindow == nil {
352 return352 return
353 }353 }
354 // Apply width config if provided354 // Apply width config if provided
@@ -371,6 +371,18 @@ func historySidebarWidthConfig(widthPx int) window.SidebarWidthConfig {
371 return cfg371 return cfg
372 }372 }
373 373
374+func (a *App) toggleHistorySidebarAction(ctx context.Context) error {
375+ bw := a.lastFocusedBrowserWindow()
376+ if bw == nil {
377+ return fmt.Errorf("history sidebar unavailable: no focused browser window")
378+ }
379+ if bw.historySidebar == nil {
380+ return fmt.Errorf("history sidebar unavailable: native sidebar not initialized")
381+ }
382+ bw.toggleHistorySidebar()
383+ return nil
384+}
385+
374 func (bw *browserWindow) applySidebarWidthConfig(a *App) {386 func (bw *browserWindow) applySidebarWidthConfig(a *App) {
375 if bw == nil || bw.mainWindow == nil || a == nil || a.deps == nil || a.deps.Config == nil {387 if bw == nil || bw.mainWindow == nil || a == nil || a.deps == nil || a.deps.Config == nil {
376 return388 return
@@ -381,7 +393,7 @@ func (bw *browserWindow) applySidebarWidthConfig(a *App) {
381 // hideHistorySidebar hides the sidebar. Callers should also restore focus393 // hideHistorySidebar hides the sidebar. Callers should also restore focus
382 // to the active content pane after calling this.394 // to the active content pane after calling this.
383 func (bw *browserWindow) hideHistorySidebar() {395 func (bw *browserWindow) hideHistorySidebar() {
384- if bw == nil || bw.historySidebar == nil {396+ if bw == nil || bw.historySidebar == nil || bw.mainWindow == nil {
385 return397 return
386 }398 }
387 bw.historySidebar.Hide()399 bw.historySidebar.Hide()
M internal/ui/browser_window_test.go +4 -33
@@ -3,7 +3,6 @@ package ui
33 import (
44 "context"
55 "errors"
6- "fmt"
76 "reflect"
87 "testing"
98 "unsafe"
@@ -1284,8 +1283,7 @@ func TestApp_HistorySidebar_ToggleThroughKeyboardDispatcher(t *testing.T) {
12841283 lastFocusedWindowID: focusedBW.id,
12851284 }
12861285
1287- // Create a KeyboardDispatcher and wire the same toggle handler closure
1288- // that App.wireKeyboardActions would register.
1286+ // Create a KeyboardDispatcher and wire the production toggle handler.
12891287 kbDispatcher := dispatcher.NewKeyboardDispatcher(
12901288 ctx,
12911289 &coordinator.WorkspaceCoordinator{},
@@ -1295,18 +1293,7 @@ func TestApp_HistorySidebar_ToggleThroughKeyboardDispatcher(t *testing.T) {
12951293 func(context.Context) entity.PaneID { return "" },
12961294 )
12971295
1298- // Wire the exact closure from App.wireKeyboardActions.
1299- kbDispatcher.SetOnToggleHistorySidebar(func(ctx context.Context) error {
1300- bw := app.lastFocusedBrowserWindow()
1301- if bw == nil {
1302- return nil
1303- }
1304- if bw.historySidebar != nil {
1305- bw.toggleHistorySidebar()
1306- return nil
1307- }
1308- return nil
1309- })
1296+ kbDispatcher.SetOnToggleHistorySidebar(app.toggleHistorySidebarAction)
13101297
13111298 // First dispatch: toggle ON.
13121299 err := kbDispatcher.Dispatch(ctx, input.ActionToggleHistorySystemView)
@@ -1345,17 +1332,7 @@ func TestApp_HistorySidebar_ToggleThroughDispatcher_UnavailableReturnsError(t *t
13451332 func(context.Context) entity.PaneID { return "" },
13461333 )
13471334
1348- kbDispatcher.SetOnToggleHistorySidebar(func(ctx context.Context) error {
1349- bw := app.lastFocusedBrowserWindow()
1350- if bw == nil {
1351- return fmt.Errorf("history sidebar unavailable: no focused browser window")
1352- }
1353- if bw.historySidebar != nil {
1354- bw.toggleHistorySidebar()
1355- return nil
1356- }
1357- return fmt.Errorf("history sidebar unavailable: native sidebar not initialized")
1358- })
1335+ kbDispatcher.SetOnToggleHistorySidebar(app.toggleHistorySidebarAction)
13591336
13601337 err := kbDispatcher.Dispatch(ctx, input.ActionToggleHistorySystemView)
13611338 require.Error(t, err)
@@ -1382,13 +1359,7 @@ func TestApp_HistorySidebar_ToggleThroughDispatcher_NilFocusedReturnsError(t *te
13821359 func(context.Context) entity.PaneID { return "" },
13831360 )
13841361
1385- kbDispatcher.SetOnToggleHistorySidebar(func(ctx context.Context) error {
1386- bw := app.lastFocusedBrowserWindow()
1387- if bw == nil {
1388- return fmt.Errorf("history sidebar unavailable: no focused browser window")
1389- }
1390- return nil
1391- })
1362+ kbDispatcher.SetOnToggleHistorySidebar(app.toggleHistorySidebarAction)
13921363
13931364 err := kbDispatcher.Dispatch(ctx, input.ActionToggleHistorySystemView)
13941365 require.Error(t, err)
@@ -3,7 +3,6 @@ package ui
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6- "fmt"
7 "reflect"6 "reflect"
8 "testing"7 "testing"
9 "unsafe"8 "unsafe"
@@ -1284,8 +1283,7 @@ func TestApp_HistorySidebar_ToggleThroughKeyboardDispatcher(t *testing.T) {
1284 lastFocusedWindowID: focusedBW.id,1283 lastFocusedWindowID: focusedBW.id,
1285 }1284 }
1286 1285
1287- // Create a KeyboardDispatcher and wire the same toggle handler closure1286+ // Create a KeyboardDispatcher and wire the production toggle handler.
1288- // that App.wireKeyboardActions would register.
1289 kbDispatcher := dispatcher.NewKeyboardDispatcher(1287 kbDispatcher := dispatcher.NewKeyboardDispatcher(
1290 ctx,1288 ctx,
1291 &coordinator.WorkspaceCoordinator{},1289 &coordinator.WorkspaceCoordinator{},
@@ -1295,18 +1293,7 @@ func TestApp_HistorySidebar_ToggleThroughKeyboardDispatcher(t *testing.T) {
1295 func(context.Context) entity.PaneID { return "" },1293 func(context.Context) entity.PaneID { return "" },
1296 )1294 )
1297 1295
1298- // Wire the exact closure from App.wireKeyboardActions.1296+ kbDispatcher.SetOnToggleHistorySidebar(app.toggleHistorySidebarAction)
1299- kbDispatcher.SetOnToggleHistorySidebar(func(ctx context.Context) error {
1300- bw := app.lastFocusedBrowserWindow()
1301- if bw == nil {
1302- return nil
1303- }
1304- if bw.historySidebar != nil {
1305- bw.toggleHistorySidebar()
1306- return nil
1307- }
1308- return nil
1309- })
1310 1297
1311 // First dispatch: toggle ON.1298 // First dispatch: toggle ON.
1312 err := kbDispatcher.Dispatch(ctx, input.ActionToggleHistorySystemView)1299 err := kbDispatcher.Dispatch(ctx, input.ActionToggleHistorySystemView)
@@ -1345,17 +1332,7 @@ func TestApp_HistorySidebar_ToggleThroughDispatcher_UnavailableReturnsError(t *t
1345 func(context.Context) entity.PaneID { return "" },1332 func(context.Context) entity.PaneID { return "" },
1346 )1333 )
1347 1334
1348- kbDispatcher.SetOnToggleHistorySidebar(func(ctx context.Context) error {1335+ kbDispatcher.SetOnToggleHistorySidebar(app.toggleHistorySidebarAction)
1349- bw := app.lastFocusedBrowserWindow()
1350- if bw == nil {
1351- return fmt.Errorf("history sidebar unavailable: no focused browser window")
1352- }
1353- if bw.historySidebar != nil {
1354- bw.toggleHistorySidebar()
1355- return nil
1356- }
1357- return fmt.Errorf("history sidebar unavailable: native sidebar not initialized")
1358- })
1359 1336
1360 err := kbDispatcher.Dispatch(ctx, input.ActionToggleHistorySystemView)1337 err := kbDispatcher.Dispatch(ctx, input.ActionToggleHistorySystemView)
1361 require.Error(t, err)1338 require.Error(t, err)
@@ -1382,13 +1359,7 @@ func TestApp_HistorySidebar_ToggleThroughDispatcher_NilFocusedReturnsError(t *te
1382 func(context.Context) entity.PaneID { return "" },1359 func(context.Context) entity.PaneID { return "" },
1383 )1360 )
1384 1361
1385- kbDispatcher.SetOnToggleHistorySidebar(func(ctx context.Context) error {1362+ kbDispatcher.SetOnToggleHistorySidebar(app.toggleHistorySidebarAction)
1386- bw := app.lastFocusedBrowserWindow()
1387- if bw == nil {
1388- return fmt.Errorf("history sidebar unavailable: no focused browser window")
1389- }
1390- return nil
1391- })
1392 1363
1393 err := kbDispatcher.Dispatch(ctx, input.ActionToggleHistorySystemView)1364 err := kbDispatcher.Dispatch(ctx, input.ActionToggleHistorySystemView)
1394 require.Error(t, err)1365 require.Error(t, err)
M internal/ui/component/history_model.go +1 -1
@@ -254,10 +254,10 @@ func (m keyboardNavModel) cumulativeOffsetAtGroup(gi int) int {
254254 // firstEntryOfGroup returns the linear index of the first entry in the
255255 // group at gi, or -1 if the group has no entries.
256256 func (m keyboardNavModel) firstEntryOfGroup(gi int) int {
257- offset := m.cumulativeOffsetAtGroup(gi)
258257 if gi < 0 || gi >= len(m.groups) {
259258 return -1
260259 }
260+ offset := m.cumulativeOffsetAtGroup(gi)
261261 firstEntry := offset + 1
262262 if firstEntry < m.totalRows() && m.isSelectable(firstEntry) {
263263 return firstEntry
@@ -254,10 +254,10 @@ func (m keyboardNavModel) cumulativeOffsetAtGroup(gi int) int {
254 // firstEntryOfGroup returns the linear index of the first entry in the254 // firstEntryOfGroup returns the linear index of the first entry in the
255 // group at gi, or -1 if the group has no entries.255 // group at gi, or -1 if the group has no entries.
256 func (m keyboardNavModel) firstEntryOfGroup(gi int) int {256 func (m keyboardNavModel) firstEntryOfGroup(gi int) int {
257- offset := m.cumulativeOffsetAtGroup(gi)
258 if gi < 0 || gi >= len(m.groups) {257 if gi < 0 || gi >= len(m.groups) {
259 return -1258 return -1
260 }259 }
260+ offset := m.cumulativeOffsetAtGroup(gi)
261 firstEntry := offset + 1261 firstEntry := offset + 1
262 if firstEntry < m.totalRows() && m.isSelectable(firstEntry) {262 if firstEntry < m.totalRows() && m.isSelectable(firstEntry) {
263 return firstEntry263 return firstEntry
M internal/ui/component/history_sidebar.go +33 -25
@@ -54,7 +54,7 @@ type HistorySidebar struct {
5454 listBox *gtk.ListBox
5555
5656 // Dependencies
57- historyUC port.HomepageHistory
57+ historyUC port.HistorySidebarHistory
5858 onURL func(ctx context.Context, url string)
5959 onOpenInNewPane func(ctx context.Context, url string) error
6060 onNavigateKeepOpen func(ctx context.Context, url string)
@@ -103,7 +103,7 @@ type HistorySidebar struct {
103103 // HistorySidebarConfig holds configuration for creating a HistorySidebar.
104104 type HistorySidebarConfig struct {
105105 // HistoryUC provides history query and delete operations.
106- HistoryUC port.HomepageHistory
106+ HistoryUC port.HistorySidebarHistory
107107
108108 // OnNavigate is called when the user activates a history entry.
109109 // The default Enter / click behavior keeps the sidebar open after navigating.
@@ -245,19 +245,27 @@ func (hs *HistorySidebar) Show() {
245245 // Schedule a background reload so the sidebar shows fresh data
246246 // when it becomes visible, not stale data captured at init time.
247247 reloadCb := glib.SourceFunc(func(uintptr) bool {
248+ hs.mu.RLock()
249+ destroyed := hs.destroyed
250+ hs.mu.RUnlock()
251+ if destroyed {
252+ return false
253+ }
248254 hs.Reload()
249255 return false
250256 })
251257 glib.IdleAdd(&reloadCb, 0)
252258
253- // Focus search entry via idle callback to ensure layout is stable
259+ // Focus search entry via idle callback to ensure layout is stable.
254260 cb := glib.SourceFunc(func(uintptr) bool {
255261 hs.mu.RLock()
262+ destroyed := hs.destroyed
256263 entry := hs.searchEntry
257264 hs.mu.RUnlock()
258- if entry != nil {
259- entry.GrabFocus()
265+ if destroyed || entry == nil {
266+ return false
260267 }
268+ entry.GrabFocus()
261269 return false
262270 })
263271 glib.IdleAdd(&cb, 0)
@@ -287,6 +295,10 @@ func (hs *HistorySidebar) IsVisible() bool {
287295 // query, scroll position, and selection.
288296 func (hs *HistorySidebar) Reload() {
289297 hs.mu.Lock()
298+ if hs.destroyed {
299+ hs.mu.Unlock()
300+ return
301+ }
290302 hs.preserveScrollAndSelection()
291303 savedQuery := hs.currentQuery
292304
@@ -446,6 +458,10 @@ func (hs *HistorySidebar) fetchPage(offset int, gen uint64) {
446458 // No provider; show empty state
447459 cb := glib.SourceFunc(func(uintptr) bool {
448460 hs.mu.Lock()
461+ if hs.destroyed {
462+ hs.mu.Unlock()
463+ return false
464+ }
449465 hs.loadStarted = false
450466 hs.isLoading = false
451467 hs.loadDone = true
@@ -798,7 +814,7 @@ func (hs *HistorySidebar) doFTSearch(query string, gen uint64) {
798814 func (hs *HistorySidebar) applySearchResults(entries []*entity.HistoryEntry, gen uint64, err error) bool {
799815 hs.mu.Lock()
800816 defer hs.mu.Unlock()
801- if gen != hs.searchGen {
817+ if hs.destroyed || gen != hs.searchGen {
802818 return false
803819 }
804820 hs.searchResults = entries
@@ -814,11 +830,13 @@ func (hs *HistorySidebar) applySearchResults(entries []*entity.HistoryEntry, gen
814830 func (hs *HistorySidebar) scheduleClearList() {
815831 cb := glib.SourceFunc(func(uintptr) bool {
816832 hs.mu.RLock()
833+ destroyed := hs.destroyed
817834 listBox := hs.listBox
818835 hs.mu.RUnlock()
819- if listBox != nil {
820- listBox.RemoveAll()
836+ if destroyed || listBox == nil {
837+ return false
821838 }
839+ listBox.RemoveAll()
822840 return false
823841 })
824842 glib.IdleAdd(&cb, 0)
@@ -1087,6 +1105,9 @@ func (hs *HistorySidebar) setupKeyboardNavigation() {
10871105
10881106 // --- Delete: remove selected entry ---
10891107 case uint(gdk.KEY_Delete), uint(gdk.KEY_KP_Delete):
1108+ if hs.searchEntry != nil && hs.searchEntry.GetText() != "" {
1109+ return false
1110+ }
10901111 return hs.handleDeleteKey()
10911112
10921113 // --- PageUp / PageDown: scroll by page ---
@@ -1373,19 +1394,7 @@ func (hs *HistorySidebar) jumpToNextDay() {
13731394 // scrollRowIntoView scrolls the scrolled window to ensure the row at
13741395 // the given ListBox index is visible.
13751396 func (hs *HistorySidebar) scrollRowIntoView(index int) {
1376- if hs.scrolledWin == nil {
1377- return
1378- }
1379- vadj := hs.scrolledWin.GetVadjustment()
1380- if vadj == nil {
1381- return
1382- }
1383- row := hs.listBox.GetRowAtIndex(index)
1384- if row == nil {
1385- return
1386- }
1387- // GrabFocus on the row scrolls it into view in a GTK ListBox
1388- row.GrabFocus()
1397+ hs.ensureRowVisible(index)
13891398 }
13901399
13911400 // jumpToFirstSelectable selects the first selectable row in the list.
@@ -1397,8 +1406,7 @@ func (hs *HistorySidebar) jumpToFirstSelectable() {
13971406 }
13981407 if row.GetSelectable() {
13991408 hs.listBox.SelectRow(row)
1400- // Scroll to visible
1401- row.GrabFocus()
1409+ hs.ensureRowVisible(i)
14021410 return
14031411 }
14041412 }
@@ -1422,14 +1430,14 @@ func (hs *HistorySidebar) jumpToLastSelectable() {
14221430 // If we found a selectable row, try it. Otherwise fall back to last row.
14231431 if lastRow != nil {
14241432 hs.listBox.SelectRow(lastRow)
1425- lastRow.GrabFocus()
1433+ hs.ensureRowVisible(lastRow.GetIndex())
14261434 return
14271435 }
14281436 // Fallback: last row regardless of selectability
14291437 if maxIdx > 0 {
14301438 if row := hs.listBox.GetRowAtIndex(maxIdx); row != nil {
14311439 hs.listBox.SelectRow(row)
1432- row.GrabFocus()
1440+ hs.ensureRowVisible(maxIdx)
14331441 }
14341442 }
14351443 }
@@ -54,7 +54,7 @@ type HistorySidebar struct {
54 listBox *gtk.ListBox54 listBox *gtk.ListBox
55 55
56 // Dependencies56 // Dependencies
57- historyUC port.HomepageHistory57+ historyUC port.HistorySidebarHistory
58 onURL func(ctx context.Context, url string)58 onURL func(ctx context.Context, url string)
59 onOpenInNewPane func(ctx context.Context, url string) error59 onOpenInNewPane func(ctx context.Context, url string) error
60 onNavigateKeepOpen func(ctx context.Context, url string)60 onNavigateKeepOpen func(ctx context.Context, url string)
@@ -103,7 +103,7 @@ type HistorySidebar struct {
103 // HistorySidebarConfig holds configuration for creating a HistorySidebar.103 // HistorySidebarConfig holds configuration for creating a HistorySidebar.
104 type HistorySidebarConfig struct {104 type HistorySidebarConfig struct {
105 // HistoryUC provides history query and delete operations.105 // HistoryUC provides history query and delete operations.
106- HistoryUC port.HomepageHistory106+ HistoryUC port.HistorySidebarHistory
107 107
108 // OnNavigate is called when the user activates a history entry.108 // OnNavigate is called when the user activates a history entry.
109 // The default Enter / click behavior keeps the sidebar open after navigating.109 // The default Enter / click behavior keeps the sidebar open after navigating.
@@ -245,19 +245,27 @@ func (hs *HistorySidebar) Show() {
245 // Schedule a background reload so the sidebar shows fresh data245 // Schedule a background reload so the sidebar shows fresh data
246 // when it becomes visible, not stale data captured at init time.246 // when it becomes visible, not stale data captured at init time.
247 reloadCb := glib.SourceFunc(func(uintptr) bool {247 reloadCb := glib.SourceFunc(func(uintptr) bool {
248+ hs.mu.RLock()
249+ destroyed := hs.destroyed
250+ hs.mu.RUnlock()
251+ if destroyed {
252+ return false
253+ }
248 hs.Reload()254 hs.Reload()
249 return false255 return false
250 })256 })
251 glib.IdleAdd(&reloadCb, 0)257 glib.IdleAdd(&reloadCb, 0)
252 258
253- // Focus search entry via idle callback to ensure layout is stable259+ // Focus search entry via idle callback to ensure layout is stable.
254 cb := glib.SourceFunc(func(uintptr) bool {260 cb := glib.SourceFunc(func(uintptr) bool {
255 hs.mu.RLock()261 hs.mu.RLock()
262+ destroyed := hs.destroyed
256 entry := hs.searchEntry263 entry := hs.searchEntry
257 hs.mu.RUnlock()264 hs.mu.RUnlock()
258- if entry != nil {265+ if destroyed || entry == nil {
259- entry.GrabFocus()266+ return false
260 }267 }
268+ entry.GrabFocus()
261 return false269 return false
262 })270 })
263 glib.IdleAdd(&cb, 0)271 glib.IdleAdd(&cb, 0)
@@ -287,6 +295,10 @@ func (hs *HistorySidebar) IsVisible() bool {
287 // query, scroll position, and selection.295 // query, scroll position, and selection.
288 func (hs *HistorySidebar) Reload() {296 func (hs *HistorySidebar) Reload() {
289 hs.mu.Lock()297 hs.mu.Lock()
298+ if hs.destroyed {
299+ hs.mu.Unlock()
300+ return
301+ }
290 hs.preserveScrollAndSelection()302 hs.preserveScrollAndSelection()
291 savedQuery := hs.currentQuery303 savedQuery := hs.currentQuery
292 304
@@ -446,6 +458,10 @@ func (hs *HistorySidebar) fetchPage(offset int, gen uint64) {
446 // No provider; show empty state458 // No provider; show empty state
447 cb := glib.SourceFunc(func(uintptr) bool {459 cb := glib.SourceFunc(func(uintptr) bool {
448 hs.mu.Lock()460 hs.mu.Lock()
461+ if hs.destroyed {
462+ hs.mu.Unlock()
463+ return false
464+ }
449 hs.loadStarted = false465 hs.loadStarted = false
450 hs.isLoading = false466 hs.isLoading = false
451 hs.loadDone = true467 hs.loadDone = true
@@ -798,7 +814,7 @@ func (hs *HistorySidebar) doFTSearch(query string, gen uint64) {
798 func (hs *HistorySidebar) applySearchResults(entries []*entity.HistoryEntry, gen uint64, err error) bool {814 func (hs *HistorySidebar) applySearchResults(entries []*entity.HistoryEntry, gen uint64, err error) bool {
799 hs.mu.Lock()815 hs.mu.Lock()
800 defer hs.mu.Unlock()816 defer hs.mu.Unlock()
801- if gen != hs.searchGen {817+ if hs.destroyed || gen != hs.searchGen {
802 return false818 return false
803 }819 }
804 hs.searchResults = entries820 hs.searchResults = entries
@@ -814,11 +830,13 @@ func (hs *HistorySidebar) applySearchResults(entries []*entity.HistoryEntry, gen
814 func (hs *HistorySidebar) scheduleClearList() {830 func (hs *HistorySidebar) scheduleClearList() {
815 cb := glib.SourceFunc(func(uintptr) bool {831 cb := glib.SourceFunc(func(uintptr) bool {
816 hs.mu.RLock()832 hs.mu.RLock()
833+ destroyed := hs.destroyed
817 listBox := hs.listBox834 listBox := hs.listBox
818 hs.mu.RUnlock()835 hs.mu.RUnlock()
819- if listBox != nil {836+ if destroyed || listBox == nil {
820- listBox.RemoveAll()837+ return false
821 }838 }
839+ listBox.RemoveAll()
822 return false840 return false
823 })841 })
824 glib.IdleAdd(&cb, 0)842 glib.IdleAdd(&cb, 0)
@@ -1087,6 +1105,9 @@ func (hs *HistorySidebar) setupKeyboardNavigation() {
1087 1105
1088 // --- Delete: remove selected entry ---1106 // --- Delete: remove selected entry ---
1089 case uint(gdk.KEY_Delete), uint(gdk.KEY_KP_Delete):1107 case uint(gdk.KEY_Delete), uint(gdk.KEY_KP_Delete):
1108+ if hs.searchEntry != nil && hs.searchEntry.GetText() != "" {
1109+ return false
1110+ }
1090 return hs.handleDeleteKey()1111 return hs.handleDeleteKey()
1091 1112
1092 // --- PageUp / PageDown: scroll by page ---1113 // --- PageUp / PageDown: scroll by page ---
@@ -1373,19 +1394,7 @@ func (hs *HistorySidebar) jumpToNextDay() {
1373 // scrollRowIntoView scrolls the scrolled window to ensure the row at1394 // scrollRowIntoView scrolls the scrolled window to ensure the row at
1374 // the given ListBox index is visible.1395 // the given ListBox index is visible.
1375 func (hs *HistorySidebar) scrollRowIntoView(index int) {1396 func (hs *HistorySidebar) scrollRowIntoView(index int) {
1376- if hs.scrolledWin == nil {1397+ hs.ensureRowVisible(index)
1377- return
1378- }
1379- vadj := hs.scrolledWin.GetVadjustment()
1380- if vadj == nil {
1381- return
1382- }
1383- row := hs.listBox.GetRowAtIndex(index)
1384- if row == nil {
1385- return
1386- }
1387- // GrabFocus on the row scrolls it into view in a GTK ListBox
1388- row.GrabFocus()
1389 }1398 }
1390 1399
1391 // jumpToFirstSelectable selects the first selectable row in the list.1400 // jumpToFirstSelectable selects the first selectable row in the list.
@@ -1397,8 +1406,7 @@ func (hs *HistorySidebar) jumpToFirstSelectable() {
1397 }1406 }
1398 if row.GetSelectable() {1407 if row.GetSelectable() {
1399 hs.listBox.SelectRow(row)1408 hs.listBox.SelectRow(row)
1400- // Scroll to visible1409+ hs.ensureRowVisible(i)
1401- row.GrabFocus()
1402 return1410 return
1403 }1411 }
1404 }1412 }
@@ -1422,14 +1430,14 @@ func (hs *HistorySidebar) jumpToLastSelectable() {
1422 // If we found a selectable row, try it. Otherwise fall back to last row.1430 // If we found a selectable row, try it. Otherwise fall back to last row.
1423 if lastRow != nil {1431 if lastRow != nil {
1424 hs.listBox.SelectRow(lastRow)1432 hs.listBox.SelectRow(lastRow)
1425- lastRow.GrabFocus()1433+ hs.ensureRowVisible(lastRow.GetIndex())
1426 return1434 return
1427 }1435 }
1428 // Fallback: last row regardless of selectability1436 // Fallback: last row regardless of selectability
1429 if maxIdx > 0 {1437 if maxIdx > 0 {
1430 if row := hs.listBox.GetRowAtIndex(maxIdx); row != nil {1438 if row := hs.listBox.GetRowAtIndex(maxIdx); row != nil {
1431 hs.listBox.SelectRow(row)1439 hs.listBox.SelectRow(row)
1432- row.GrabFocus()1440+ hs.ensureRowVisible(maxIdx)
1433 }1441 }
1434 }1442 }
1435 }1443 }
M internal/ui/component/history_sidebar_search_test.go +62 -38
@@ -8,11 +8,9 @@ import (
88 "time"
99
1010 "github.com/bnema/dumber/internal/application/dto"
11- appportmocks "github.com/bnema/dumber/internal/application/port/mocks"
1211 "github.com/bnema/dumber/internal/domain/entity"
1312 "github.com/rs/zerolog"
1413 "github.com/stretchr/testify/assert"
15- "github.com/stretchr/testify/mock"
1614 "github.com/stretchr/testify/require"
1715 )
1816
@@ -120,23 +118,48 @@ func TestApplySearchResults_EmptyResultsApplied(t *testing.T) {
120118 }
121119
122120 // =============================================================================
123-// doFTSearch seam: controllable history port mock
121+// doFTSearch seam: controllable history port fake
124122 // =============================================================================
125123
126-func newMockHomepageHistory(t *testing.T) *appportmocks.MockHomepageHistory {
127- return appportmocks.NewMockHomepageHistory(t)
124+type fakeHistorySidebarHistory struct {
125+ getRecentFn func(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
126+ searchFn func(ctx context.Context, input dto.HistorySearchInput) (*dto.HistorySearchOutput, error)
127+ deleteFn func(ctx context.Context, id int64) error
128+}
129+
130+func (f *fakeHistorySidebarHistory) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
131+ if f.getRecentFn != nil {
132+ return f.getRecentFn(ctx, limit, offset)
133+ }
134+ <-make(chan struct{})
135+ return nil, nil
136+}
137+
138+func (f *fakeHistorySidebarHistory) Search(ctx context.Context, input dto.HistorySearchInput) (*dto.HistorySearchOutput, error) {
139+ if f.searchFn != nil {
140+ return f.searchFn(ctx, input)
141+ }
142+ return &dto.HistorySearchOutput{Matches: []entity.HistoryMatch{}}, nil
143+}
144+
145+func (f *fakeHistorySidebarHistory) Delete(ctx context.Context, id int64) error {
146+ if f.deleteFn != nil {
147+ return f.deleteFn(ctx, id)
148+ }
149+ return nil
128150 }
129151
130152 func TestDoFTSearch_WithFakeUC_StaleGenerationDropsResults(t *testing.T) {
131153 searchCalled := make(chan struct{}, 1)
132154
133- history := newMockHomepageHistory(t)
134- history.EXPECT().Search(mock.Anything, dto.HistorySearchInput{Query: "test", Limit: sidebarSearchLimit}).RunAndReturn(func(context.Context, dto.HistorySearchInput) (*dto.HistorySearchOutput, error) {
135- searchCalled <- struct{}{}
136- return &dto.HistorySearchOutput{Matches: []entity.HistoryMatch{
137- {Entry: &entity.HistoryEntry{ID: 1, URL: "https://result.com", Title: "Result", LastVisited: time.Now()}},
138- }}, nil
139- })
155+ history := &fakeHistorySidebarHistory{
156+ searchFn: func(context.Context, dto.HistorySearchInput) (*dto.HistorySearchOutput, error) {
157+ searchCalled <- struct{}{}
158+ return &dto.HistorySearchOutput{Matches: []entity.HistoryMatch{
159+ {Entry: &entity.HistoryEntry{ID: 1, URL: "https://result.com", Title: "Result", LastVisited: time.Now()}},
160+ }}, nil
161+ },
162+ }
140163
141164 hs := newTestSidebarSearchHarness()
142165 hs.ctx = context.Background()
@@ -147,21 +170,19 @@ func TestDoFTSearch_WithFakeUC_StaleGenerationDropsResults(t *testing.T) {
147170 hs.doFTSearch("test", 1)
148171 <-searchCalled // Wait for the goroutine to pick up the search
149172
150- // Advance gen before the idle callback can apply results.
151- // The callback runs inside the goroutine after the search completes.
173+ // Advance gen before the UI callback would apply results.
152174 hs.mu.Lock()
153175 hs.searchGen = 2
154176 hs.mu.Unlock()
155177
156- // Wait briefly for the idle callback to attempt applying.
157- // Since gen=1 != gen=2, results should be silently dropped
158- // (glib.IdleAdd is a no-op outside GTK, but we verify the
159- // gen comparison via applySearchResults).
160- time.Sleep(50 * time.Millisecond)
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")
161182
162183 hs.mu.RLock()
163184 defer hs.mu.RUnlock()
164- assert.Nil(t, hs.searchResults, "stale search results must be dropped")
185+ assert.Nil(t, hs.searchResults)
165186 assert.False(t, hs.searchDone)
166187 assert.Nil(t, hs.groups)
167188 }
@@ -169,13 +190,14 @@ func TestDoFTSearch_WithFakeUC_StaleGenerationDropsResults(t *testing.T) {
169190 func TestDoFTSearch_WithFakeUC_CurrentGenApplied(t *testing.T) {
170191 searchCalled := make(chan struct{}, 1)
171192
172- history := newMockHomepageHistory(t)
173- history.EXPECT().Search(mock.Anything, dto.HistorySearchInput{Query: "live", Limit: sidebarSearchLimit}).RunAndReturn(func(context.Context, dto.HistorySearchInput) (*dto.HistorySearchOutput, error) {
174- searchCalled <- struct{}{}
175- return &dto.HistorySearchOutput{Matches: []entity.HistoryMatch{
176- {Entry: &entity.HistoryEntry{ID: 1, URL: "https://live.com", Title: "Live", LastVisited: time.Now()}},
177- }}, nil
178- })
193+ history := &fakeHistorySidebarHistory{
194+ searchFn: func(context.Context, dto.HistorySearchInput) (*dto.HistorySearchOutput, error) {
195+ searchCalled <- struct{}{}
196+ return &dto.HistorySearchOutput{Matches: []entity.HistoryMatch{
197+ {Entry: &entity.HistoryEntry{ID: 1, URL: "https://live.com", Title: "Live", LastVisited: time.Now()}},
198+ }}, nil
199+ },
200+ }
179201
180202 hs := newTestSidebarSearchHarness()
181203 hs.ctx = context.Background()
@@ -212,11 +234,12 @@ func TestDoFTSearch_WithFakeUC_CurrentGenApplied(t *testing.T) {
212234 // active query while resetting browse/search state before the refreshed load.
213235 func TestHistorySidebar_ReloadPreservesQuery(t *testing.T) {
214236 searchCalled := make(chan struct{}, 1)
215- history := newMockHomepageHistory(t)
216- history.EXPECT().Search(mock.Anything, dto.HistorySearchInput{Query: "preserved", Limit: sidebarSearchLimit}).RunAndReturn(func(context.Context, dto.HistorySearchInput) (*dto.HistorySearchOutput, error) {
217- searchCalled <- struct{}{}
218- return &dto.HistorySearchOutput{Matches: []entity.HistoryMatch{}}, nil
219- })
237+ history := &fakeHistorySidebarHistory{
238+ searchFn: func(context.Context, dto.HistorySearchInput) (*dto.HistorySearchOutput, error) {
239+ searchCalled <- struct{}{}
240+ return &dto.HistorySearchOutput{Matches: []entity.HistoryMatch{}}, nil
241+ },
242+ }
220243
221244 hs := newTestSidebarSearchHarness()
222245 hs.currentQuery = "preserved"
@@ -266,12 +289,13 @@ func TestFetchPage_StaleGenerationDoesNotMutateLoadingState(t *testing.T) {
266289 getRecentCalled := make(chan struct{})
267290 proceed := make(chan struct{})
268291
269- history := newMockHomepageHistory(t)
270- history.EXPECT().GetRecent(mock.Anything, sidebarPageSize, 0).RunAndReturn(func(context.Context, int, int) ([]*entity.HistoryEntry, error) {
271- close(getRecentCalled)
272- <-proceed
273- return []*entity.HistoryEntry{}, nil
274- })
292+ history := &fakeHistorySidebarHistory{
293+ getRecentFn: func(context.Context, int, int) ([]*entity.HistoryEntry, error) {
294+ close(getRecentCalled)
295+ <-proceed
296+ return []*entity.HistoryEntry{}, nil
297+ },
298+ }
275299
276300 hs := newTestSidebarSearchHarness()
277301 hs.ctx = context.Background()
@@ -8,11 +8,9 @@ import (
8 "time"8 "time"
9 9
10 "github.com/bnema/dumber/internal/application/dto"10 "github.com/bnema/dumber/internal/application/dto"
11- appportmocks "github.com/bnema/dumber/internal/application/port/mocks"
12 "github.com/bnema/dumber/internal/domain/entity"11 "github.com/bnema/dumber/internal/domain/entity"
13 "github.com/rs/zerolog"12 "github.com/rs/zerolog"
14 "github.com/stretchr/testify/assert"13 "github.com/stretchr/testify/assert"
15- "github.com/stretchr/testify/mock"
16 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
17 )15 )
18 16
@@ -120,23 +118,48 @@ func TestApplySearchResults_EmptyResultsApplied(t *testing.T) {
120 }118 }
121 119
122 // =============================================================================120 // =============================================================================
123-// doFTSearch seam: controllable history port mock121+// doFTSearch seam: controllable history port fake
124 // =============================================================================122 // =============================================================================
125 123
126-func newMockHomepageHistory(t *testing.T) *appportmocks.MockHomepageHistory {124+type fakeHistorySidebarHistory struct {
127- return appportmocks.NewMockHomepageHistory(t)125+ getRecentFn func(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
126+ searchFn func(ctx context.Context, input dto.HistorySearchInput) (*dto.HistorySearchOutput, error)
127+ deleteFn func(ctx context.Context, id int64) error
128+}
129+
130+func (f *fakeHistorySidebarHistory) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
131+ if f.getRecentFn != nil {
132+ return f.getRecentFn(ctx, limit, offset)
133+ }
134+ <-make(chan struct{})
135+ return nil, nil
136+}
137+
138+func (f *fakeHistorySidebarHistory) Search(ctx context.Context, input dto.HistorySearchInput) (*dto.HistorySearchOutput, error) {
139+ if f.searchFn != nil {
140+ return f.searchFn(ctx, input)
141+ }
142+ return &dto.HistorySearchOutput{Matches: []entity.HistoryMatch{}}, nil
143+}
144+
145+func (f *fakeHistorySidebarHistory) Delete(ctx context.Context, id int64) error {
146+ if f.deleteFn != nil {
147+ return f.deleteFn(ctx, id)
148+ }
149+ return nil
128 }150 }
129 151
130 func TestDoFTSearch_WithFakeUC_StaleGenerationDropsResults(t *testing.T) {152 func TestDoFTSearch_WithFakeUC_StaleGenerationDropsResults(t *testing.T) {
131 searchCalled := make(chan struct{}, 1)153 searchCalled := make(chan struct{}, 1)
132 154
133- history := newMockHomepageHistory(t)155+ history := &fakeHistorySidebarHistory{
134- history.EXPECT().Search(mock.Anything, dto.HistorySearchInput{Query: "test", Limit: sidebarSearchLimit}).RunAndReturn(func(context.Context, dto.HistorySearchInput) (*dto.HistorySearchOutput, error) {156+ searchFn: func(context.Context, dto.HistorySearchInput) (*dto.HistorySearchOutput, error) {
135- searchCalled <- struct{}{}157+ searchCalled <- struct{}{}
136- return &dto.HistorySearchOutput{Matches: []entity.HistoryMatch{158+ return &dto.HistorySearchOutput{Matches: []entity.HistoryMatch{
137- {Entry: &entity.HistoryEntry{ID: 1, URL: "https://result.com", Title: "Result", LastVisited: time.Now()}},159+ {Entry: &entity.HistoryEntry{ID: 1, URL: "https://result.com", Title: "Result", LastVisited: time.Now()}},
138- }}, nil160+ }}, nil
139- })161+ },
162+ }
140 163
141 hs := newTestSidebarSearchHarness()164 hs := newTestSidebarSearchHarness()
142 hs.ctx = context.Background()165 hs.ctx = context.Background()
@@ -147,21 +170,19 @@ func TestDoFTSearch_WithFakeUC_StaleGenerationDropsResults(t *testing.T) {
147 hs.doFTSearch("test", 1)170 hs.doFTSearch("test", 1)
148 <-searchCalled // Wait for the goroutine to pick up the search171 <-searchCalled // Wait for the goroutine to pick up the search
149 172
150- // Advance gen before the idle callback can apply results.173+ // Advance gen before the UI callback would apply results.
151- // The callback runs inside the goroutine after the search completes.
152 hs.mu.Lock()174 hs.mu.Lock()
153 hs.searchGen = 2175 hs.searchGen = 2
154 hs.mu.Unlock()176 hs.mu.Unlock()
155 177
156- // Wait briefly for the idle callback to attempt applying.178+ applied := hs.applySearchResults([]*entity.HistoryEntry{{
157- // Since gen=1 != gen=2, results should be silently dropped179+ ID: 1, URL: "https://result.com", Title: "Result", LastVisited: time.Now(),
158- // (glib.IdleAdd is a no-op outside GTK, but we verify the180+ }}, 1, nil)
159- // gen comparison via applySearchResults).181+ assert.False(t, applied, "stale search results must be dropped")
160- time.Sleep(50 * time.Millisecond)
161 182
162 hs.mu.RLock()183 hs.mu.RLock()
163 defer hs.mu.RUnlock()184 defer hs.mu.RUnlock()
164- assert.Nil(t, hs.searchResults, "stale search results must be dropped")185+ assert.Nil(t, hs.searchResults)
165 assert.False(t, hs.searchDone)186 assert.False(t, hs.searchDone)
166 assert.Nil(t, hs.groups)187 assert.Nil(t, hs.groups)
167 }188 }
@@ -169,13 +190,14 @@ func TestDoFTSearch_WithFakeUC_StaleGenerationDropsResults(t *testing.T) {
169 func TestDoFTSearch_WithFakeUC_CurrentGenApplied(t *testing.T) {190 func TestDoFTSearch_WithFakeUC_CurrentGenApplied(t *testing.T) {
170 searchCalled := make(chan struct{}, 1)191 searchCalled := make(chan struct{}, 1)
171 192
172- history := newMockHomepageHistory(t)193+ history := &fakeHistorySidebarHistory{
173- history.EXPECT().Search(mock.Anything, dto.HistorySearchInput{Query: "live", Limit: sidebarSearchLimit}).RunAndReturn(func(context.Context, dto.HistorySearchInput) (*dto.HistorySearchOutput, error) {194+ searchFn: func(context.Context, dto.HistorySearchInput) (*dto.HistorySearchOutput, error) {
174- searchCalled <- struct{}{}195+ searchCalled <- struct{}{}
175- return &dto.HistorySearchOutput{Matches: []entity.HistoryMatch{196+ return &dto.HistorySearchOutput{Matches: []entity.HistoryMatch{
176- {Entry: &entity.HistoryEntry{ID: 1, URL: "https://live.com", Title: "Live", LastVisited: time.Now()}},197+ {Entry: &entity.HistoryEntry{ID: 1, URL: "https://live.com", Title: "Live", LastVisited: time.Now()}},
177- }}, nil198+ }}, nil
178- })199+ },
200+ }
179 201
180 hs := newTestSidebarSearchHarness()202 hs := newTestSidebarSearchHarness()
181 hs.ctx = context.Background()203 hs.ctx = context.Background()
@@ -212,11 +234,12 @@ func TestDoFTSearch_WithFakeUC_CurrentGenApplied(t *testing.T) {
212 // active query while resetting browse/search state before the refreshed load.234 // active query while resetting browse/search state before the refreshed load.
213 func TestHistorySidebar_ReloadPreservesQuery(t *testing.T) {235 func TestHistorySidebar_ReloadPreservesQuery(t *testing.T) {
214 searchCalled := make(chan struct{}, 1)236 searchCalled := make(chan struct{}, 1)
215- history := newMockHomepageHistory(t)237+ history := &fakeHistorySidebarHistory{
216- history.EXPECT().Search(mock.Anything, dto.HistorySearchInput{Query: "preserved", Limit: sidebarSearchLimit}).RunAndReturn(func(context.Context, dto.HistorySearchInput) (*dto.HistorySearchOutput, error) {238+ searchFn: func(context.Context, dto.HistorySearchInput) (*dto.HistorySearchOutput, error) {
217- searchCalled <- struct{}{}239+ searchCalled <- struct{}{}
218- return &dto.HistorySearchOutput{Matches: []entity.HistoryMatch{}}, nil240+ return &dto.HistorySearchOutput{Matches: []entity.HistoryMatch{}}, nil
219- })241+ },
242+ }
220 243
221 hs := newTestSidebarSearchHarness()244 hs := newTestSidebarSearchHarness()
222 hs.currentQuery = "preserved"245 hs.currentQuery = "preserved"
@@ -266,12 +289,13 @@ func TestFetchPage_StaleGenerationDoesNotMutateLoadingState(t *testing.T) {
266 getRecentCalled := make(chan struct{})289 getRecentCalled := make(chan struct{})
267 proceed := make(chan struct{})290 proceed := make(chan struct{})
268 291
269- history := newMockHomepageHistory(t)292+ history := &fakeHistorySidebarHistory{
270- history.EXPECT().GetRecent(mock.Anything, sidebarPageSize, 0).RunAndReturn(func(context.Context, int, int) ([]*entity.HistoryEntry, error) {293+ getRecentFn: func(context.Context, int, int) ([]*entity.HistoryEntry, error) {
271- close(getRecentCalled)294+ close(getRecentCalled)
272- <-proceed295+ <-proceed
273- return []*entity.HistoryEntry{}, nil296+ return []*entity.HistoryEntry{}, nil
274- })297+ },
298+ }
275 299
276 hs := newTestSidebarSearchHarness()300 hs := newTestSidebarSearchHarness()
277 hs.ctx = context.Background()301 hs.ctx = context.Background()
M internal/ui/window/main_window.go +4 -3
@@ -230,14 +230,15 @@ func (mw *MainWindow) SetSidebarWidth(cfg SidebarWidthConfig) {
230230 if mw.sidebarBox == nil {
231231 return
232232 }
233+ defaults := SidebarDefaultWidth()
233234 if cfg.MinPx == 0 {
234- cfg.MinPx = 280
235+ cfg.MinPx = defaults.MinPx
235236 }
236237 if cfg.MaxPx == 0 {
237- cfg.MaxPx = 380
238+ cfg.MaxPx = defaults.MaxPx
238239 }
239240 if cfg.WidthPx == 0 {
240- cfg.WidthPx = 320
241+ cfg.WidthPx = defaults.WidthPx
241242 }
242243 clamped := cfg.WidthPx
243244 if clamped < cfg.MinPx {
@@ -230,14 +230,15 @@ func (mw *MainWindow) SetSidebarWidth(cfg SidebarWidthConfig) {
230 if mw.sidebarBox == nil {230 if mw.sidebarBox == nil {
231 return231 return
232 }232 }
233+ defaults := SidebarDefaultWidth()
233 if cfg.MinPx == 0 {234 if cfg.MinPx == 0 {
234- cfg.MinPx = 280235+ cfg.MinPx = defaults.MinPx
235 }236 }
236 if cfg.MaxPx == 0 {237 if cfg.MaxPx == 0 {
237- cfg.MaxPx = 380238+ cfg.MaxPx = defaults.MaxPx
238 }239 }
239 if cfg.WidthPx == 0 {240 if cfg.WidthPx == 0 {
240- cfg.WidthPx = 320241+ cfg.WidthPx = defaults.WidthPx
241 }242 }
242 clamped := cfg.WidthPx243 clamped := cfg.WidthPx
243 if clamped < cfg.MinPx {244 if clamped < cfg.MinPx {

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