fix(history): address review findings
- commit
- 9b42f5be9300660a320a6b982486a139040a493c
- author
- brice · · 2026-06-13 06:38 UTC
- parent
- 9a9b787
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 ( | |||
| 15 | 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 | 20 | var _ port.HomepageHistory = (*SearchHistoryUseCase)(nil) | |
| 21 | + | var _ port.HistorySidebarHistory = (*SearchHistoryUseCase)(nil) | |
| 20 | 22 | ||
| 21 | 23 | const ( | |
| 22 | 24 | 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.Hour | 24 | historyWindowDuration = 24 * time.Hour | ||
M internal/ui/app.go +1 -11
| @@ -3522,17 +3522,7 @@ func (a *App) wireKeyboardActions() { | |||
| 3522 | 3522 | } | |
| 3523 | 3523 | return a.EjectActivePaneToWindow(ctx, paneID) | |
| 3524 | 3524 | }) | |
| 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) | |
| 3536 | 3526 | a.kbDispatcher.SetOnToggleFloatingPane(func(ctx context.Context) error { | |
| 3537 | 3527 | return a.ToggleFloatingPane(ctx) | |
| 3538 | 3528 | }) | |
| @@ -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) { | |||
| 301 | 301 | // buildHistorySidebarConfig constructs the HistorySidebarConfig for the given | |
| 302 | 302 | // browser window. Extracted from initHistorySidebar for testability. | |
| 303 | 303 | func (a *App) buildHistorySidebarConfig(ctx context.Context, bw *browserWindow) component.HistorySidebarConfig { | |
| 304 | - | var historyUC port.HomepageHistory | |
| 304 | + | var historyUC port.HistorySidebarHistory | |
| 305 | 305 | if a.deps != nil { | |
| 306 | 306 | historyUC = a.deps.HistoryUC | |
| 307 | 307 | } | |
| @@ -348,7 +348,7 @@ func (bw *browserWindow) toggleHistorySidebar(widthCfg ...window.SidebarWidthCon | |||
| 348 | 348 | // showHistorySidebar makes the sidebar visible and grabs focus for the search | |
| 349 | 349 | // entry. An optional width config can be provided to override the default width. | |
| 350 | 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 | 352 | return | |
| 353 | 353 | } | |
| 354 | 354 | // Apply width config if provided | |
| @@ -371,6 +371,18 @@ func historySidebarWidthConfig(widthPx int) window.SidebarWidthConfig { | |||
| 371 | 371 | 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 | 386 | func (bw *browserWindow) applySidebarWidthConfig(a *App) { | |
| 375 | 387 | if bw == nil || bw.mainWindow == nil || a == nil || a.deps == nil || a.deps.Config == nil { | |
| 376 | 388 | return | |
| @@ -381,7 +393,7 @@ func (bw *browserWindow) applySidebarWidthConfig(a *App) { | |||
| 381 | 393 | // hideHistorySidebar hides the sidebar. Callers should also restore focus | |
| 382 | 394 | // to the active content pane after calling this. | |
| 383 | 395 | func (bw *browserWindow) hideHistorySidebar() { | |
| 384 | - | if bw == nil || bw.historySidebar == nil { | |
| 396 | + | if bw == nil || bw.historySidebar == nil || bw.mainWindow == nil { | |
| 385 | 397 | return | |
| 386 | 398 | } | |
| 387 | 399 | bw.historySidebar.Hide() | |
| @@ -301,7 +301,7 @@ func (bw *browserWindow) initHistorySidebar(ctx context.Context, a *App) { | |||||
| 301 | // buildHistorySidebarConfig constructs the HistorySidebarConfig for the given | 301 | // 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.HomepageHistory | 304 | + | var historyUC port.HistorySidebarHistory |
| 305 | if a.deps != nil { | 305 | if a.deps != nil { | ||
| 306 | historyUC = a.deps.HistoryUC | 306 | 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 search | 348 | // 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 | return | 352 | return | ||
| 353 | } | 353 | } | ||
| 354 | // Apply width config if provided | 354 | // Apply width config if provided | ||
| @@ -371,6 +371,18 @@ func historySidebarWidthConfig(widthPx int) window.SidebarWidthConfig { | |||||
| 371 | return cfg | 371 | 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 | return | 388 | return | ||
| @@ -381,7 +393,7 @@ func (bw *browserWindow) applySidebarWidthConfig(a *App) { | |||||
| 381 | // hideHistorySidebar hides the sidebar. Callers should also restore focus | 393 | // 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 | return | 397 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | - | "fmt" | |
| 7 | 6 | "reflect" | |
| 8 | 7 | "testing" | |
| 9 | 8 | "unsafe" | |
| @@ -1284,8 +1283,7 @@ func TestApp_HistorySidebar_ToggleThroughKeyboardDispatcher(t *testing.T) { | |||
| 1284 | 1283 | lastFocusedWindowID: focusedBW.id, | |
| 1285 | 1284 | } | |
| 1286 | 1285 | ||
| 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. | |
| 1289 | 1287 | kbDispatcher := dispatcher.NewKeyboardDispatcher( | |
| 1290 | 1288 | ctx, | |
| 1291 | 1289 | &coordinator.WorkspaceCoordinator{}, | |
| @@ -1295,18 +1293,7 @@ func TestApp_HistorySidebar_ToggleThroughKeyboardDispatcher(t *testing.T) { | |||
| 1295 | 1293 | func(context.Context) entity.PaneID { return "" }, | |
| 1296 | 1294 | ) | |
| 1297 | 1295 | ||
| 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) | |
| 1310 | 1297 | ||
| 1311 | 1298 | // First dispatch: toggle ON. | |
| 1312 | 1299 | err := kbDispatcher.Dispatch(ctx, input.ActionToggleHistorySystemView) | |
| @@ -1345,17 +1332,7 @@ func TestApp_HistorySidebar_ToggleThroughDispatcher_UnavailableReturnsError(t *t | |||
| 1345 | 1332 | func(context.Context) entity.PaneID { return "" }, | |
| 1346 | 1333 | ) | |
| 1347 | 1334 | ||
| 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) | |
| 1359 | 1336 | ||
| 1360 | 1337 | err := kbDispatcher.Dispatch(ctx, input.ActionToggleHistorySystemView) | |
| 1361 | 1338 | require.Error(t, err) | |
| @@ -1382,13 +1359,7 @@ func TestApp_HistorySidebar_ToggleThroughDispatcher_NilFocusedReturnsError(t *te | |||
| 1382 | 1359 | func(context.Context) entity.PaneID { return "" }, | |
| 1383 | 1360 | ) | |
| 1384 | 1361 | ||
| 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) | |
| 1392 | 1363 | ||
| 1393 | 1364 | err := kbDispatcher.Dispatch(ctx, input.ActionToggleHistorySystemView) | |
| 1394 | 1365 | 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 closure | 1286 | + | // 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 { | |||
| 254 | 254 | // firstEntryOfGroup returns the linear index of the first entry in the | |
| 255 | 255 | // group at gi, or -1 if the group has no entries. | |
| 256 | 256 | func (m keyboardNavModel) firstEntryOfGroup(gi int) int { | |
| 257 | - | offset := m.cumulativeOffsetAtGroup(gi) | |
| 258 | 257 | if gi < 0 || gi >= len(m.groups) { | |
| 259 | 258 | return -1 | |
| 260 | 259 | } | |
| 260 | + | offset := m.cumulativeOffsetAtGroup(gi) | |
| 261 | 261 | firstEntry := offset + 1 | |
| 262 | 262 | if firstEntry < m.totalRows() && m.isSelectable(firstEntry) { | |
| 263 | 263 | return firstEntry | |
| @@ -254,10 +254,10 @@ func (m keyboardNavModel) cumulativeOffsetAtGroup(gi int) int { | |||||
| 254 | // firstEntryOfGroup returns the linear index of the first entry in the | 254 | // 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 -1 | 258 | return -1 | ||
| 260 | } | 259 | } | ||
| 260 | + | offset := m.cumulativeOffsetAtGroup(gi) | |||
| 261 | firstEntry := offset + 1 | 261 | firstEntry := offset + 1 | ||
| 262 | if firstEntry < m.totalRows() && m.isSelectable(firstEntry) { | 262 | if firstEntry < m.totalRows() && m.isSelectable(firstEntry) { | ||
| 263 | return firstEntry | 263 | return firstEntry | ||
M internal/ui/component/history_sidebar.go +33 -25
| @@ -54,7 +54,7 @@ type HistorySidebar struct { | |||
| 54 | 54 | listBox *gtk.ListBox | |
| 55 | 55 | ||
| 56 | 56 | // Dependencies | |
| 57 | - | historyUC port.HomepageHistory | |
| 57 | + | historyUC port.HistorySidebarHistory | |
| 58 | 58 | onURL func(ctx context.Context, url string) | |
| 59 | 59 | onOpenInNewPane func(ctx context.Context, url string) error | |
| 60 | 60 | onNavigateKeepOpen func(ctx context.Context, url string) | |
| @@ -103,7 +103,7 @@ type HistorySidebar struct { | |||
| 103 | 103 | // HistorySidebarConfig holds configuration for creating a HistorySidebar. | |
| 104 | 104 | type HistorySidebarConfig struct { | |
| 105 | 105 | // HistoryUC provides history query and delete operations. | |
| 106 | - | HistoryUC port.HomepageHistory | |
| 106 | + | HistoryUC port.HistorySidebarHistory | |
| 107 | 107 | ||
| 108 | 108 | // OnNavigate is called when the user activates a history entry. | |
| 109 | 109 | // The default Enter / click behavior keeps the sidebar open after navigating. | |
| @@ -245,19 +245,27 @@ func (hs *HistorySidebar) Show() { | |||
| 245 | 245 | // Schedule a background reload so the sidebar shows fresh data | |
| 246 | 246 | // when it becomes visible, not stale data captured at init time. | |
| 247 | 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 | 254 | hs.Reload() | |
| 249 | 255 | return false | |
| 250 | 256 | }) | |
| 251 | 257 | glib.IdleAdd(&reloadCb, 0) | |
| 252 | 258 | ||
| 253 | - | // Focus search entry via idle callback to ensure layout is stable | |
| 259 | + | // Focus search entry via idle callback to ensure layout is stable. | |
| 254 | 260 | cb := glib.SourceFunc(func(uintptr) bool { | |
| 255 | 261 | hs.mu.RLock() | |
| 262 | + | destroyed := hs.destroyed | |
| 256 | 263 | entry := hs.searchEntry | |
| 257 | 264 | hs.mu.RUnlock() | |
| 258 | - | if entry != nil { | |
| 259 | - | entry.GrabFocus() | |
| 265 | + | if destroyed || entry == nil { | |
| 266 | + | return false | |
| 260 | 267 | } | |
| 268 | + | entry.GrabFocus() | |
| 261 | 269 | return false | |
| 262 | 270 | }) | |
| 263 | 271 | glib.IdleAdd(&cb, 0) | |
| @@ -287,6 +295,10 @@ func (hs *HistorySidebar) IsVisible() bool { | |||
| 287 | 295 | // query, scroll position, and selection. | |
| 288 | 296 | func (hs *HistorySidebar) Reload() { | |
| 289 | 297 | hs.mu.Lock() | |
| 298 | + | if hs.destroyed { | |
| 299 | + | hs.mu.Unlock() | |
| 300 | + | return | |
| 301 | + | } | |
| 290 | 302 | hs.preserveScrollAndSelection() | |
| 291 | 303 | savedQuery := hs.currentQuery | |
| 292 | 304 | ||
| @@ -446,6 +458,10 @@ func (hs *HistorySidebar) fetchPage(offset int, gen uint64) { | |||
| 446 | 458 | // No provider; show empty state | |
| 447 | 459 | cb := glib.SourceFunc(func(uintptr) bool { | |
| 448 | 460 | hs.mu.Lock() | |
| 461 | + | if hs.destroyed { | |
| 462 | + | hs.mu.Unlock() | |
| 463 | + | return false | |
| 464 | + | } | |
| 449 | 465 | hs.loadStarted = false | |
| 450 | 466 | hs.isLoading = false | |
| 451 | 467 | hs.loadDone = true | |
| @@ -798,7 +814,7 @@ func (hs *HistorySidebar) doFTSearch(query string, gen uint64) { | |||
| 798 | 814 | func (hs *HistorySidebar) applySearchResults(entries []*entity.HistoryEntry, gen uint64, err error) bool { | |
| 799 | 815 | hs.mu.Lock() | |
| 800 | 816 | defer hs.mu.Unlock() | |
| 801 | - | if gen != hs.searchGen { | |
| 817 | + | if hs.destroyed || gen != hs.searchGen { | |
| 802 | 818 | return false | |
| 803 | 819 | } | |
| 804 | 820 | hs.searchResults = entries | |
| @@ -814,11 +830,13 @@ func (hs *HistorySidebar) applySearchResults(entries []*entity.HistoryEntry, gen | |||
| 814 | 830 | func (hs *HistorySidebar) scheduleClearList() { | |
| 815 | 831 | cb := glib.SourceFunc(func(uintptr) bool { | |
| 816 | 832 | hs.mu.RLock() | |
| 833 | + | destroyed := hs.destroyed | |
| 817 | 834 | listBox := hs.listBox | |
| 818 | 835 | hs.mu.RUnlock() | |
| 819 | - | if listBox != nil { | |
| 820 | - | listBox.RemoveAll() | |
| 836 | + | if destroyed || listBox == nil { | |
| 837 | + | return false | |
| 821 | 838 | } | |
| 839 | + | listBox.RemoveAll() | |
| 822 | 840 | return false | |
| 823 | 841 | }) | |
| 824 | 842 | glib.IdleAdd(&cb, 0) | |
| @@ -1087,6 +1105,9 @@ func (hs *HistorySidebar) setupKeyboardNavigation() { | |||
| 1087 | 1105 | ||
| 1088 | 1106 | // --- Delete: remove selected entry --- | |
| 1089 | 1107 | case uint(gdk.KEY_Delete), uint(gdk.KEY_KP_Delete): | |
| 1108 | + | if hs.searchEntry != nil && hs.searchEntry.GetText() != "" { | |
| 1109 | + | return false | |
| 1110 | + | } | |
| 1090 | 1111 | return hs.handleDeleteKey() | |
| 1091 | 1112 | ||
| 1092 | 1113 | // --- PageUp / PageDown: scroll by page --- | |
| @@ -1373,19 +1394,7 @@ func (hs *HistorySidebar) jumpToNextDay() { | |||
| 1373 | 1394 | // scrollRowIntoView scrolls the scrolled window to ensure the row at | |
| 1374 | 1395 | // the given ListBox index is visible. | |
| 1375 | 1396 | 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) | |
| 1389 | 1398 | } | |
| 1390 | 1399 | ||
| 1391 | 1400 | // jumpToFirstSelectable selects the first selectable row in the list. | |
| @@ -1397,8 +1406,7 @@ func (hs *HistorySidebar) jumpToFirstSelectable() { | |||
| 1397 | 1406 | } | |
| 1398 | 1407 | if row.GetSelectable() { | |
| 1399 | 1408 | hs.listBox.SelectRow(row) | |
| 1400 | - | // Scroll to visible | |
| 1401 | - | row.GrabFocus() | |
| 1409 | + | hs.ensureRowVisible(i) | |
| 1402 | 1410 | return | |
| 1403 | 1411 | } | |
| 1404 | 1412 | } | |
| @@ -1422,14 +1430,14 @@ func (hs *HistorySidebar) jumpToLastSelectable() { | |||
| 1422 | 1430 | // If we found a selectable row, try it. Otherwise fall back to last row. | |
| 1423 | 1431 | if lastRow != nil { | |
| 1424 | 1432 | hs.listBox.SelectRow(lastRow) | |
| 1425 | - | lastRow.GrabFocus() | |
| 1433 | + | hs.ensureRowVisible(lastRow.GetIndex()) | |
| 1426 | 1434 | return | |
| 1427 | 1435 | } | |
| 1428 | 1436 | // Fallback: last row regardless of selectability | |
| 1429 | 1437 | if maxIdx > 0 { | |
| 1430 | 1438 | if row := hs.listBox.GetRowAtIndex(maxIdx); row != nil { | |
| 1431 | 1439 | hs.listBox.SelectRow(row) | |
| 1432 | - | row.GrabFocus() | |
| 1440 | + | hs.ensureRowVisible(maxIdx) | |
| 1433 | 1441 | } | |
| 1434 | 1442 | } | |
| 1435 | 1443 | } | |
| @@ -54,7 +54,7 @@ type HistorySidebar struct { | |||||
| 54 | listBox *gtk.ListBox | 54 | listBox *gtk.ListBox | ||
| 55 | 55 | ||||
| 56 | // Dependencies | 56 | // Dependencies | ||
| 57 | - | historyUC port.HomepageHistory | 57 | + | 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) error | 59 | 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.HomepageHistory | 106 | + | 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 data | 245 | // 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 false | 255 | 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 stable | 259 | + | // 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.searchEntry | 263 | 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 false | 269 | 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.currentQuery | 303 | savedQuery := hs.currentQuery | ||
| 292 | 304 | ||||
| @@ -446,6 +458,10 @@ func (hs *HistorySidebar) fetchPage(offset int, gen uint64) { | |||||
| 446 | // No provider; show empty state | 458 | // 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 = false | 465 | hs.loadStarted = false | ||
| 450 | hs.isLoading = false | 466 | hs.isLoading = false | ||
| 451 | hs.loadDone = true | 467 | 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 false | 818 | return false | ||
| 803 | } | 819 | } | ||
| 804 | hs.searchResults = entries | 820 | 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.listBox | 834 | 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 false | 840 | 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 at | 1394 | // 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 visible | 1409 | + | hs.ensureRowVisible(i) |
| 1401 | - | row.GrabFocus() | |||
| 1402 | return | 1410 | 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 | return | 1434 | return | ||
| 1427 | } | 1435 | } | ||
| 1428 | // Fallback: last row regardless of selectability | 1436 | // 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 ( | |||
| 8 | 8 | "time" | |
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/dumber/internal/application/dto" | |
| 11 | - | appportmocks "github.com/bnema/dumber/internal/application/port/mocks" | |
| 12 | 11 | "github.com/bnema/dumber/internal/domain/entity" | |
| 13 | 12 | "github.com/rs/zerolog" | |
| 14 | 13 | "github.com/stretchr/testify/assert" | |
| 15 | - | "github.com/stretchr/testify/mock" | |
| 16 | 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 mock | |
| 121 | + | // doFTSearch seam: controllable history port fake | |
| 124 | 122 | // ============================================================================= | |
| 125 | 123 | ||
| 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 | |
| 128 | 150 | } | |
| 129 | 151 | ||
| 130 | 152 | func TestDoFTSearch_WithFakeUC_StaleGenerationDropsResults(t *testing.T) { | |
| 131 | 153 | searchCalled := make(chan struct{}, 1) | |
| 132 | 154 | ||
| 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 | + | } | |
| 140 | 163 | ||
| 141 | 164 | hs := newTestSidebarSearchHarness() | |
| 142 | 165 | hs.ctx = context.Background() | |
| @@ -147,21 +170,19 @@ func TestDoFTSearch_WithFakeUC_StaleGenerationDropsResults(t *testing.T) { | |||
| 147 | 170 | hs.doFTSearch("test", 1) | |
| 148 | 171 | <-searchCalled // Wait for the goroutine to pick up the search | |
| 149 | 172 | ||
| 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. | |
| 152 | 174 | hs.mu.Lock() | |
| 153 | 175 | hs.searchGen = 2 | |
| 154 | 176 | hs.mu.Unlock() | |
| 155 | 177 | ||
| 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") | |
| 161 | 182 | ||
| 162 | 183 | hs.mu.RLock() | |
| 163 | 184 | defer hs.mu.RUnlock() | |
| 164 | - | assert.Nil(t, hs.searchResults, "stale search results must be dropped") | |
| 185 | + | assert.Nil(t, hs.searchResults) | |
| 165 | 186 | assert.False(t, hs.searchDone) | |
| 166 | 187 | assert.Nil(t, hs.groups) | |
| 167 | 188 | } | |
| @@ -169,13 +190,14 @@ func TestDoFTSearch_WithFakeUC_StaleGenerationDropsResults(t *testing.T) { | |||
| 169 | 190 | func TestDoFTSearch_WithFakeUC_CurrentGenApplied(t *testing.T) { | |
| 170 | 191 | searchCalled := make(chan struct{}, 1) | |
| 171 | 192 | ||
| 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 | + | } | |
| 179 | 201 | ||
| 180 | 202 | hs := newTestSidebarSearchHarness() | |
| 181 | 203 | hs.ctx = context.Background() | |
| @@ -212,11 +234,12 @@ func TestDoFTSearch_WithFakeUC_CurrentGenApplied(t *testing.T) { | |||
| 212 | 234 | // active query while resetting browse/search state before the refreshed load. | |
| 213 | 235 | func TestHistorySidebar_ReloadPreservesQuery(t *testing.T) { | |
| 214 | 236 | 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 | + | } | |
| 220 | 243 | ||
| 221 | 244 | hs := newTestSidebarSearchHarness() | |
| 222 | 245 | hs.currentQuery = "preserved" | |
| @@ -266,12 +289,13 @@ func TestFetchPage_StaleGenerationDoesNotMutateLoadingState(t *testing.T) { | |||
| 266 | 289 | getRecentCalled := make(chan struct{}) | |
| 267 | 290 | proceed := make(chan struct{}) | |
| 268 | 291 | ||
| 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 | + | } | |
| 275 | 299 | ||
| 276 | 300 | hs := newTestSidebarSearchHarness() | |
| 277 | 301 | 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 mock | 121 | + | // 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 | - | }}, nil | 160 | + | }}, 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 search | 171 | <-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 = 2 | 175 | 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 dropped | 179 | + | ID: 1, URL: "https://result.com", Title: "Result", LastVisited: time.Now(), |
| 158 | - | // (glib.IdleAdd is a no-op outside GTK, but we verify the | 180 | + | }}, 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 | - | }}, nil | 198 | + | }}, 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{}}, nil | 240 | + | 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 | - | <-proceed | 295 | + | <-proceed |
| 273 | - | return []*entity.HistoryEntry{}, nil | 296 | + | 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) { | |||
| 230 | 230 | if mw.sidebarBox == nil { | |
| 231 | 231 | return | |
| 232 | 232 | } | |
| 233 | + | defaults := SidebarDefaultWidth() | |
| 233 | 234 | if cfg.MinPx == 0 { | |
| 234 | - | cfg.MinPx = 280 | |
| 235 | + | cfg.MinPx = defaults.MinPx | |
| 235 | 236 | } | |
| 236 | 237 | if cfg.MaxPx == 0 { | |
| 237 | - | cfg.MaxPx = 380 | |
| 238 | + | cfg.MaxPx = defaults.MaxPx | |
| 238 | 239 | } | |
| 239 | 240 | if cfg.WidthPx == 0 { | |
| 240 | - | cfg.WidthPx = 320 | |
| 241 | + | cfg.WidthPx = defaults.WidthPx | |
| 241 | 242 | } | |
| 242 | 243 | clamped := cfg.WidthPx | |
| 243 | 244 | 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 | return | 231 | return | ||
| 232 | } | 232 | } | ||
| 233 | + | defaults := SidebarDefaultWidth() | |||
| 233 | if cfg.MinPx == 0 { | 234 | if cfg.MinPx == 0 { | ||
| 234 | - | cfg.MinPx = 280 | 235 | + | cfg.MinPx = defaults.MinPx |
| 235 | } | 236 | } | ||
| 236 | if cfg.MaxPx == 0 { | 237 | if cfg.MaxPx == 0 { | ||
| 237 | - | cfg.MaxPx = 380 | 238 | + | cfg.MaxPx = defaults.MaxPx |
| 238 | } | 239 | } | ||
| 239 | if cfg.WidthPx == 0 { | 240 | if cfg.WidthPx == 0 { | ||
| 240 | - | cfg.WidthPx = 320 | 241 | + | cfg.WidthPx = defaults.WidthPx |
| 241 | } | 242 | } | ||
| 242 | clamped := cfg.WidthPx | 243 | clamped := cfg.WidthPx | ||
| 243 | if clamped < cfg.MinPx { | 244 | if clamped < cfg.MinPx { | ||