detached cbe806a

Merge pull request #304 from bnema/feat/gtk-history-sidebar

feat(history): add native GTK Ctrl+H sidebar
commit
cbe806a78844d4bdbbdb628bacb737b4ace709a6
author
bnema · · 2026-06-13 09:45 UTC
committer
GitHub · · 2026-06-13 09:45 UTC
merge of
ac3d540 + 217c6f0 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
31 files · +5035 -88
M .mockery.yaml +1 -0
@@ -65,6 +65,7 @@ packages:
65 EntryInputTarget: {}65 EntryInputTarget: {}
66 HomepageFavorites: {}66 HomepageFavorites: {}
67 HomepageHistory: {}67 HomepageHistory: {}
68+ HistorySidebarHistory: {}
68 AllKeybindingsResetter: {}69 AllKeybindingsResetter: {}
69 KeybindingResetter: {}70 KeybindingResetter: {}
70 KeybindingSetter: {}71 KeybindingSetter: {}
M docs/reference/keybindings.md +2 -2
@@ -83,7 +83,7 @@ These work outside modal modes:
83 | Action | Keys |83 | Action | Keys |
84 |--------|------|84 |--------|------|
85 | Toggle floating pane | `Alt+F` |85 | Toggle floating pane | `Alt+F` |
86-| Toggle History system view in right split (may conflict with the browser's default History shortcut; behavior can vary by browser) | `Ctrl+H` |86+| Toggle History sidebar (native GTK sidebar panel only). Ctrl+H may conflict with the browser's default History shortcut; behavior can vary by browser. | `Ctrl+H` |
87 | Toggle Favorites system view in right split | unbound by default |87 | Toggle Favorites system view in right split | unbound by default |
88 | Toggle Config system view in right split | unbound by default |88 | Toggle Config system view in right split | unbound by default |
89 | Close pane (or release floating pane) | `Ctrl+W` |89 | Close pane (or release floating pane) | `Ctrl+W` |
@@ -96,7 +96,7 @@ These work outside modal modes:
96 96
97 - `Alt+F` is the only floating-pane shortcut enabled by default.97 - `Alt+F` is the only floating-pane shortcut enabled by default.
98 - `Alt+F` toggles floating visibility and keeps floating pane state intact.98 - `Alt+F` toggles floating visibility and keeps floating pane state intact.
99-- `Ctrl+H` toggles `dumb://history`: it focuses an existing History pane, opens it in a right split if missing, or closes it when already active. Depending on the browser, the default History shortcut may intercept `Ctrl+H`, so behavior can vary.99+- `Ctrl+H` toggles the native GTK history sidebar. The sidebar shows browsing history grouped by day with search/filter, keyboard navigation (arrows, Home/End, Ctrl+arrows for day jumps), and activation modes (Enter to navigate while keeping the sidebar open, Ctrl+Enter to navigate while keeping the sidebar open, Shift+Enter to open in a new split). If the native sidebar is unavailable, the shortcut returns an error instead of falling back to `dumb://history`.
100 - `Ctrl+W` closes the active pane; when the floating pane is active, it fully releases that floating session.100 - `Ctrl+W` closes the active pane; when the floating pane is active, it fully releases that floating session.
101 - Any URL shortcut (for example `Alt+G`) must be defined explicitly in `workspace.floating_pane.profiles`.101 - Any URL shortcut (for example `Alt+G`) must be defined explicitly in `workspace.floating_pane.profiles`.
102 - Floating profile shortcuts support modifier combos with `ctrl`, `shift`, and `alt` (for example `ctrl+shift+y` or `ctrl+alt+m`).102 - Floating profile shortcuts support modifier combos with `ctrl`, `shift`, and `alt` (for example `ctrl+shift+y` or `ctrl+alt+m`).
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+}
M internal/application/usecase/search_history.go +3 -1
@@ -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/infrastructure/config/defaults.go +5 -3
@@ -21,7 +21,8 @@ const (
21 defaultMaxLogFiles = 100 // session log files21 defaultMaxLogFiles = 100 // session log files
22 22
23 // Appearance defaults23 // Appearance defaults
24- defaultFontSize = 16 // points24+ defaultSidebarWidth = 320 // px, clamped to [280, 380]
25+ defaultFontSize = 16 // points
25 defaultExternalThemeProvider = "noctalia"26 defaultExternalThemeProvider = "noctalia"
26 defaultExternalThemeFormat = "colors-json"27 defaultExternalThemeFormat = "colors-json"
27 defaultExternalThemeColorsFilename = "colors.json"28 defaultExternalThemeColorsFilename = "colors.json"
@@ -251,8 +252,9 @@ func DefaultConfig() *Config {
251 GLRenderingMode: GLRenderingModeAuto,252 GLRenderingMode: GLRenderingModeAuto,
252 },253 },
253 },254 },
254- DefaultWebpageZoom: 1.2, // 120% default zoom for better readability255+ DefaultWebpageZoom: 1.2, // 120% default zoom for better readability
255- DefaultUIScale: defaultUIScale, // 1.0 = 100%, 2.0 = 200%256+ DefaultUIScale: defaultUIScale, // 1.0 = 100%, 2.0 = 200%
257+ SidebarWidth: defaultSidebarWidth, // 320px, clamped to [280, 380]
256 Workspace: WorkspaceConfig{258 Workspace: WorkspaceConfig{
257 NewPaneURL: defaultNewPaneURL,259 NewPaneURL: defaultNewPaneURL,
258 SwitchToTabOnMove: true,260 SwitchToTabOnMove: true,
M internal/infrastructure/config/loader.go +1 -0
@@ -664,6 +664,7 @@ func (m *Manager) setAppearanceDefaults(defaults *Config) {
664 func (m *Manager) setZoomAndScaleDefaults(defaults *Config) {664 func (m *Manager) setZoomAndScaleDefaults(defaults *Config) {
665 m.viper.SetDefault("default_webpage_zoom", defaults.DefaultWebpageZoom)665 m.viper.SetDefault("default_webpage_zoom", defaults.DefaultWebpageZoom)
666 m.viper.SetDefault("default_ui_scale", defaults.DefaultUIScale)666 m.viper.SetDefault("default_ui_scale", defaults.DefaultUIScale)
667+ m.viper.SetDefault("sidebar_width", defaults.SidebarWidth)
667 }668 }
668 669
669 func (m *Manager) setWorkspaceDefaults(defaults *Config) {670 func (m *Manager) setWorkspaceDefaults(defaults *Config) {
M internal/infrastructure/config/migrate.go +4 -4
@@ -283,10 +283,10 @@ const (
283 283
284 // Config format constants.284 // Config format constants.
285 const (285 const (
286- configFormatTOML = "toml"286+ configFormatTOML = "toml"
287- configFormatYAML = "yaml"287+ configFormatYAML = "yaml"
288- configFormatJSON = "json"288+ configFormatJSON = "json"
289- databasePathKey = "database.path"289+ databasePathKey = "database.path"
290 )290 )
291 291
292 type defaultActionMap struct {292 type defaultActionMap struct {
M internal/infrastructure/config/schema.go +3 -0
@@ -95,6 +95,9 @@ type Config struct {
95 DefaultWebpageZoom float64 `mapstructure:"default_webpage_zoom" yaml:"default_webpage_zoom" toml:"default_webpage_zoom"`95 DefaultWebpageZoom float64 `mapstructure:"default_webpage_zoom" yaml:"default_webpage_zoom" toml:"default_webpage_zoom"`
96 // DefaultUIScale sets the default UI scale for GTK widgets (1.0 = 100%, 2.0 = 200%)96 // DefaultUIScale sets the default UI scale for GTK widgets (1.0 = 100%, 2.0 = 200%)
97 DefaultUIScale float64 `mapstructure:"default_ui_scale" yaml:"default_ui_scale" toml:"default_ui_scale"`97 DefaultUIScale float64 `mapstructure:"default_ui_scale" yaml:"default_ui_scale" toml:"default_ui_scale"`
98+ // SidebarWidth sets the preferred width (px) for the history sidebar.
99+ // Clamped to [280, 380] at runtime. 0 means use default (320).
100+ SidebarWidth int `mapstructure:"sidebar_width" yaml:"sidebar_width" toml:"sidebar_width"`
98 // Workspace defines workspace, pane, and tab handling behavior.101 // Workspace defines workspace, pane, and tab handling behavior.
99 Workspace WorkspaceConfig `mapstructure:"workspace" yaml:"workspace" toml:"workspace"`102 Workspace WorkspaceConfig `mapstructure:"workspace" yaml:"workspace" toml:"workspace"`
100 // Session controls session persistence and restoration.103 // Session controls session persistence and restoration.
M internal/infrastructure/config/schema_provider.go +8 -0
@@ -161,6 +161,14 @@ func (*SchemaProvider) getAppearanceKeys(defaults *Config) []entity.ConfigKeyInf
161 Range: "0.5-3.0",161 Range: "0.5-3.0",
162 Section: SectionAppearance,162 Section: SectionAppearance,
163 },163 },
164+ {
165+ Key: "sidebar_width",
166+ Type: "int",
167+ Default: fmt.Sprintf("%d", defaults.SidebarWidth),
168+ Description: "Preferred width (px) for the history sidebar. 0 = default",
169+ Range: "0 or 280-380",
170+ Section: SectionAppearance,
171+ },
164 {172 {
165 Key: "appearance.light_palette.*",173 Key: "appearance.light_palette.*",
166 Type: "string",174 Type: "string",
M internal/infrastructure/config/validation.go +3 -0
@@ -104,6 +104,9 @@ func validateAppearance(config *Config) []string {
104 if config.DefaultUIScale < 0.5 || config.DefaultUIScale > 3.0 {104 if config.DefaultUIScale < 0.5 || config.DefaultUIScale > 3.0 {
105 validationErrors = append(validationErrors, "default_ui_scale must be between 0.5 and 3.0")105 validationErrors = append(validationErrors, "default_ui_scale must be between 0.5 and 3.0")
106 }106 }
107+ if config.SidebarWidth != 0 && (config.SidebarWidth < 280 || config.SidebarWidth > 380) {
108+ validationErrors = append(validationErrors, "sidebar_width must be between 280 and 380, or 0 for default")
109+ }
107 validationErrors = append(validationErrors, validateExternalTheme(config)...)110 validationErrors = append(validationErrors, validateExternalTheme(config)...)
108 return validationErrors111 return validationErrors
109 }112 }
M internal/ui/app.go +18 -0
@@ -2222,6 +2222,20 @@ func (a *App) activeWorkspaceViewForBrowserWindow(bw *browserWindow) *component.
2222 return a.workspaceViews[tab.ID]2222 return a.workspaceViews[tab.ID]
2223 }2223 }
2224 2224
2225+// hideAndRestoreFocusForBrowserWindow hides the sidebar and restores focus to
2226+// the active content pane/webview of the given browser window.
2227+func (a *App) hideAndRestoreFocusForBrowserWindow(bw *browserWindow) {
2228+ if bw == nil {
2229+ return
2230+ }
2231+ bw.hideHistorySidebar()
2232+ if wsView := a.activeWorkspaceViewForBrowserWindow(bw); wsView != nil {
2233+ if ws := a.activeWorkspaceForBrowserWindow(bw); ws != nil {
2234+ wsView.FocusPane(ws.ActivePaneID)
2235+ }
2236+ }
2237+}
2238+
2225 // tabTargetForBrowserWindow returns a coordinator.TabTarget scoped to the given browser window.2239 // tabTargetForBrowserWindow returns a coordinator.TabTarget scoped to the given browser window.
2226 // It does not allocate a TabList; callers that mutate tabs should use2240 // It does not allocate a TabList; callers that mutate tabs should use
2227 // ensureTabTargetForBrowserWindow instead.2241 // ensureTabTargetForBrowserWindow instead.
@@ -3508,6 +3522,7 @@ func (a *App) wireKeyboardActions() {
3508 }3522 }
3509 return a.EjectActivePaneToWindow(ctx, paneID)3523 return a.EjectActivePaneToWindow(ctx, paneID)
3510 })3524 })
3525+ a.kbDispatcher.SetOnToggleHistorySidebar(a.toggleHistorySidebarAction)
3511 a.kbDispatcher.SetOnToggleFloatingPane(func(ctx context.Context) error {3526 a.kbDispatcher.SetOnToggleFloatingPane(func(ctx context.Context) error {
3512 return a.ToggleFloatingPane(ctx)3527 return a.ToggleFloatingPane(ctx)
3513 })3528 })
@@ -5037,6 +5052,9 @@ func (a *App) initConfigWatcher(ctx context.Context) {
5037 if bw == nil {5052 if bw == nil {
5038 continue5053 continue
5039 }5054 }
5055+ // Reapply sidebar width from live config (reloads after sidebar_width
5056+ // changes in the config file).
5057+ bw.applySidebarWidthConfig(a)
5040 if bw.keyboardHandler != nil {5058 if bw.keyboardHandler != nil {
5041 bw.keyboardHandler.ReloadShortcuts(ctx, &a.deps.Config.Workspace, &a.deps.Config.Session)5059 bw.keyboardHandler.ReloadShortcuts(ctx, &a.deps.Config.Workspace, &a.deps.Config.Session)
5042 }5060 }
M internal/ui/browser_window.go +18 -0
@@ -37,6 +37,8 @@ type browserWindow struct {
37 globalShortcutHandler *input.GlobalShortcutHandler37 globalShortcutHandler *input.GlobalShortcutHandler
38 permissionDialog port.PermissionDialogPresenter38 permissionDialog port.PermissionDialogPresenter
39 webrtcIndicator *component.WebRTCPermissionIndicator39 webrtcIndicator *component.WebRTCPermissionIndicator
40+ historySidebar *component.HistorySidebar
41+ sidebarVisible bool
40 }42 }
41 43
42 func (bw *browserWindow) detachInputForDestroy() {44 func (bw *browserWindow) detachInputForDestroy() {
@@ -63,6 +65,12 @@ func (bw *browserWindow) clearShellState() {
63 if bw == nil {65 if bw == nil {
64 return66 return
65 }67 }
68+ // Destroy the history sidebar before releasing the reference so its
69+ // context, debounce timer, callbacks, and in-flight goroutines are
70+ // cleaned up before the window itself is torn down.
71+ if bw.historySidebar != nil {
72+ bw.historySidebar.Destroy()
73+ }
66 bw.appToaster = nil74 bw.appToaster = nil
67 bw.modeToaster = nil75 bw.modeToaster = nil
68 bw.borderMgr = nil76 bw.borderMgr = nil
@@ -76,6 +84,7 @@ func (bw *browserWindow) clearShellState() {
76 bw.globalShortcutHandler = nil84 bw.globalShortcutHandler = nil
77 bw.permissionDialog = nil85 bw.permissionDialog = nil
78 bw.webrtcIndicator = nil86 bw.webrtcIndicator = nil
87+ bw.historySidebar = nil
79 }88 }
80 89
81 func (bw *browserWindow) initChrome(ctx context.Context, a *App) {90 func (bw *browserWindow) initChrome(ctx context.Context, a *App) {
@@ -88,6 +97,7 @@ func (bw *browserWindow) initChrome(ctx context.Context, a *App) {
88 bw.initAccentPicker(ctx, a)97 bw.initAccentPicker(ctx, a)
89 bw.initSessionManager(ctx, a)98 bw.initSessionManager(ctx, a)
90 bw.initTabPicker(ctx, a)99 bw.initTabPicker(ctx, a)
100+ bw.initHistorySidebar(ctx, a)
91 }101 }
92 102
93 func (bw *browserWindow) initToasterOverlay(a *App) {103 func (bw *browserWindow) initToasterOverlay(a *App) {
@@ -259,6 +269,14 @@ func (bw *browserWindow) ensureTabs() {
259 }269 }
260 }270 }
261 271
272+func (a *App) hasBrowserWindow(target *browserWindow) bool {
273+ if a == nil || target == nil || a.browserWindows == nil {
274+ return false
275+ }
276+ bw, ok := a.browserWindows[target.id]
277+ return ok && bw == target
278+}
279+
262 func (a *App) registerBrowserWindow(bw *browserWindow) {280 func (a *App) registerBrowserWindow(bw *browserWindow) {
263 if bw == nil {281 if bw == nil {
264 return282 return
A internal/ui/browser_window_history_sidebar.go +152 -0
@@ -0,0 +1,152 @@
1+package ui
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/logging"
10+ "github.com/bnema/dumber/internal/ui/component"
11+ "github.com/bnema/dumber/internal/ui/window"
12+)
13+
14+// initHistorySidebar creates and mounts the history sidebar into the
15+// browser window's sidebar container. The sidebar is hidden by default.
16+func (bw *browserWindow) initHistorySidebar(ctx context.Context, a *App) {
17+ if bw == nil || a == nil || bw.mainWindow == nil || a.deps == nil || a.deps.HistoryUC == nil {
18+ return
19+ }
20+ log := logging.FromContext(ctx)
21+
22+ cfg := a.buildHistorySidebarConfig(bw)
23+
24+ sidebar := component.NewHistorySidebar(ctx, cfg)
25+ if sidebar == nil {
26+ log.Warn().Msg("failed to create history sidebar")
27+ return
28+ }
29+
30+ bw.historySidebar = sidebar
31+
32+ // Mount into the main window's sidebar box.
33+ bw.mainWindow.SetSidebarWidget(sidebar.Widget())
34+ bw.historySidebar.Hide()
35+ bw.mainWindow.SetSidebarVisible(false)
36+ bw.sidebarVisible = false
37+
38+ // Apply sidebar width from config, falling back to the default 320px.
39+ // The width is clamped to [280, 380] by SetSidebarWidth internally.
40+ bw.applySidebarWidthConfig(a)
41+
42+ log.Debug().Msg("history sidebar initialized")
43+}
44+
45+// buildHistorySidebarConfig constructs the HistorySidebarConfig for the given
46+// browser window. Extracted from initHistorySidebar for testability.
47+func (a *App) buildHistorySidebarConfig(bw *browserWindow) component.HistorySidebarConfig {
48+ var historyUC port.HistorySidebarHistory
49+ if a.deps != nil {
50+ historyUC = a.deps.HistoryUC
51+ }
52+
53+ return component.HistorySidebarConfig{
54+ HistoryUC: historyUC,
55+ OnNavigate: func(navCtx context.Context, url string) error {
56+ return a.navigateHistorySidebarSelection(navCtx, bw, url)
57+ },
58+ OnNavigateKeepOpen: func(navCtx context.Context, url string) error {
59+ return a.navigateHistorySidebarSelection(navCtx, bw, url)
60+ },
61+ OnOpenInNewPane: func(splitCtx context.Context, url string) error {
62+ if a.wsCoord == nil || !a.hasBrowserWindow(bw) {
63+ return nil
64+ }
65+ a.activateBrowserWindow(bw)
66+ return a.wsCoord.SplitWithURL(splitCtx, usecase.SplitRight, url)
67+ },
68+ OnClose: func() {
69+ a.hideAndRestoreFocusForBrowserWindow(bw)
70+ },
71+ }
72+}
73+
74+func (a *App) navigateHistorySidebarSelection(ctx context.Context, bw *browserWindow, url string) error {
75+ if a == nil || bw == nil || !a.hasBrowserWindow(bw) {
76+ return nil
77+ }
78+ return a.navigateFromBrowserWindow(ctx, bw, url)
79+}
80+
81+// toggleHistorySidebar toggles sidebar visibility.
82+func (bw *browserWindow) toggleHistorySidebar() {
83+ if bw == nil || bw.historySidebar == nil {
84+ return
85+ }
86+
87+ if bw.sidebarVisible {
88+ bw.hideHistorySidebar()
89+ } else {
90+ bw.showHistorySidebar()
91+ }
92+}
93+
94+// showHistorySidebar makes the sidebar visible and grabs focus for the search
95+// entry.
96+func (bw *browserWindow) showHistorySidebar() {
97+ if bw == nil || bw.historySidebar == nil || bw.mainWindow == nil {
98+ return
99+ }
100+ bw.historySidebar.Show()
101+ bw.mainWindow.SetSidebarVisible(true)
102+ bw.sidebarVisible = true
103+}
104+
105+// hideHistorySidebar hides the sidebar. Callers should also restore focus
106+// to the active content pane after calling this.
107+func (bw *browserWindow) hideHistorySidebar() {
108+ if bw == nil || bw.historySidebar == nil || bw.mainWindow == nil {
109+ return
110+ }
111+ bw.historySidebar.Hide()
112+ bw.mainWindow.SetSidebarVisible(false)
113+ bw.sidebarVisible = false
114+}
115+
116+// historySidebarWidthConfig extracts the config-backed sidebar width and
117+// applies it via the MainWindow.SetSidebarWidth path. It is called during
118+// initialization and can be reused if config is reloaded at runtime.
119+func historySidebarWidthConfig(widthPx int) window.SidebarWidthConfig {
120+ cfg := window.SidebarDefaultWidth()
121+ if widthPx > 0 {
122+ cfg.WidthPx = widthPx
123+ }
124+ return cfg
125+}
126+
127+// applySidebarWidthConfig applies the config-backed sidebar width to the
128+// main window's sidebar.
129+func (bw *browserWindow) applySidebarWidthConfig(a *App) {
130+ if bw == nil || bw.mainWindow == nil || a == nil || a.deps == nil || a.deps.Config == nil {
131+ return
132+ }
133+ bw.mainWindow.SetSidebarWidth(historySidebarWidthConfig(a.deps.Config.SidebarWidth))
134+}
135+
136+// toggleHistorySidebarAction is the keyboard-action handler for toggling the
137+// history sidebar on the last focused browser window.
138+func (a *App) toggleHistorySidebarAction(ctx context.Context) error {
139+ bw := a.lastFocusedBrowserWindow()
140+ if bw == nil {
141+ return fmt.Errorf("history sidebar unavailable: no focused browser window")
142+ }
143+ if bw.historySidebar == nil {
144+ return fmt.Errorf("history sidebar unavailable: native sidebar not initialized")
145+ }
146+ if bw.sidebarVisible {
147+ a.hideAndRestoreFocusForBrowserWindow(bw)
148+ return nil
149+ }
150+ bw.toggleHistorySidebar()
151+ return nil
152+}
A internal/ui/browser_window_history_sidebar_test.go +891 -0
@@ -0,0 +1,891 @@
1+package ui
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/usecase"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/infrastructure/config"
10+ "github.com/bnema/dumber/internal/ui/component"
11+ "github.com/bnema/dumber/internal/ui/coordinator"
12+ contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"
13+ "github.com/bnema/dumber/internal/ui/dispatcher"
14+ "github.com/bnema/dumber/internal/ui/input"
15+ "github.com/bnema/dumber/internal/ui/window"
16+ "github.com/stretchr/testify/assert"
17+ "github.com/stretchr/testify/require"
18+)
19+
20+// =============================================================================
21+// History sidebar integration tests
22+// =============================================================================
23+
24+// TestHistorySidebarConfig_OnNavigateNavigatesActivePaneAndKeepsSidebar verifies
25+// that the default OnNavigate callback targets the owning browser window's
26+// active pane and keeps the sidebar visible.
27+func TestHistorySidebarConfig_OnNavigateNavigatesActivePaneAndKeepsSidebar(t *testing.T) {
28+ ctx := t.Context()
29+
30+ // Build two browser windows with independent tabs and panes.
31+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
32+ tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
33+
34+ firstTabs := entity.NewTabList()
35+ firstTabs.Add(tab1)
36+ firstTabs.SetActive(tab1.ID)
37+ first := &browserWindow{id: "window-1", tabs: firstTabs}
38+
39+ secondTabs := entity.NewTabList()
40+ secondTabs.Add(tab2)
41+ secondTabs.SetActive(tab2.ID)
42+ second := &browserWindow{id: "window-2", tabs: secondTabs}
43+
44+ // Create fake webviews, one per pane.
45+ fakeWv1 := &fakeRecordingWebView{id: 1}
46+ fakeWv2 := &fakeRecordingWebView{id: 2}
47+
48+ contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
49+ contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), fakeWv1)
50+ contentCoord.RegisterPopupWebView(entity.PaneID("pane-2"), fakeWv2)
51+
52+ navCoord := coordinator.NewNavigationCoordinator(ctx, nil, contentCoord)
53+
54+ app := &App{
55+ tabs: entity.NewTabList(),
56+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
57+ lastFocusedWindowID: first.id,
58+ contentCoord: contentCoord,
59+ navCoord: navCoord,
60+ workspaceViews: map[entity.TabID]*component.WorkspaceView{
61+ tab1.ID: {},
62+ tab2.ID: {},
63+ },
64+ }
65+ app.tabs.Add(tab1)
66+ app.tabs.Add(tab2)
67+
68+ second.mainWindow = &window.MainWindow{}
69+ second.historySidebar = &component.HistorySidebar{}
70+ second.sidebarVisible = true
71+
72+ cfg := app.buildHistorySidebarConfig(second)
73+ navigateURL := "https://example.com"
74+ err := cfg.OnNavigate(ctx, navigateURL)
75+ require.NoError(t, err, "OnNavigate should succeed")
76+
77+ // The second window's webview must have received the navigation.
78+ assert.True(t, fakeWv2.loadURICalled, "second window webview should receive navigation")
79+ assert.Equal(t, navigateURL, fakeWv2.loadURILastURI)
80+ assert.True(t, second.sidebarVisible, "default history activation should keep the sidebar visible")
81+
82+ // The first window's webview must NOT have been touched (stale-focus guard).
83+ assert.False(t, fakeWv1.loadURICalled, "first window webview must not receive navigation from second window callback")
84+}
85+
86+// TestHistorySidebarConfig_OnNavigateKeepOpenNavigatesWithoutClosing verifies
87+// that the OnNavigateKeepOpen callback (Ctrl+Enter) navigates the owning window's
88+// active pane and keeps the sidebar visible.
89+func TestHistorySidebarConfig_OnNavigateKeepOpenNavigatesWithoutClosing(t *testing.T) {
90+ ctx := t.Context()
91+
92+ tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
93+ bwTabs := entity.NewTabList()
94+ bwTabs.Add(tab)
95+ bwTabs.SetActive(tab.ID)
96+
97+ bw := &browserWindow{
98+ id: "window-1",
99+ tabs: bwTabs,
100+ mainWindow: &window.MainWindow{},
101+ historySidebar: &component.HistorySidebar{},
102+ sidebarVisible: true,
103+ }
104+
105+ contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
106+ fakeWv := &fakeRecordingWebView{id: 1}
107+ contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), fakeWv)
108+ navCoord := coordinator.NewNavigationCoordinator(ctx, nil, contentCoord)
109+
110+ app := &App{
111+ tabs: entity.NewTabList(),
112+ browserWindows: map[string]*browserWindow{bw.id: bw},
113+ contentCoord: contentCoord,
114+ navCoord: navCoord,
115+ workspaceViews: map[entity.TabID]*component.WorkspaceView{
116+ tab.ID: {},
117+ },
118+ }
119+ app.tabs.Add(tab)
120+
121+ cfg := app.buildHistorySidebarConfig(bw)
122+ navigateURL := "https://keep-open.com"
123+ err := cfg.OnNavigateKeepOpen(ctx, navigateURL)
124+ require.NoError(t, err)
125+ assert.True(t, fakeWv.loadURICalled)
126+ assert.Equal(t, navigateURL, fakeWv.loadURILastURI, "Ctrl+Enter navigation should go to the URL")
127+ assert.True(t, bw.sidebarVisible, "Ctrl+Enter navigation should keep the sidebar visible")
128+}
129+
130+// TestHistorySidebar_OwnershipOnMultiWindowNavigation verifies that when
131+// multiple browser windows have history sidebars, navigation targets the
132+// correct owning window's active pane. This tests the stale-focus scenario
133+// where a different window is globally focused.
134+func TestHistorySidebar_OwnershipOnMultiWindowNavigation(t *testing.T) {
135+ ctx := t.Context()
136+
137+ // Two windows, each with their own tab and pane.
138+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
139+ tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
140+
141+ firstTabs := entity.NewTabList()
142+ firstTabs.Add(tab1)
143+ firstTabs.SetActive(tab1.ID)
144+ first := &browserWindow{id: "window-1", tabs: firstTabs}
145+
146+ secondTabs := entity.NewTabList()
147+ secondTabs.Add(tab2)
148+ secondTabs.SetActive(tab2.ID)
149+ second := &browserWindow{id: "window-2", tabs: secondTabs}
150+
151+ fakeWv1 := &fakeRecordingWebView{id: 1}
152+ fakeWv2 := &fakeRecordingWebView{id: 2}
153+
154+ contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
155+ contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), fakeWv1)
156+ contentCoord.RegisterPopupWebView(entity.PaneID("pane-2"), fakeWv2)
157+
158+ navCoord := coordinator.NewNavigationCoordinator(ctx, nil, contentCoord)
159+
160+ app := &App{
161+ tabs: entity.NewTabList(),
162+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
163+ lastFocusedWindowID: first.id, // STALE: first is globally focused
164+ contentCoord: contentCoord,
165+ navCoord: navCoord,
166+ workspaceViews: map[entity.TabID]*component.WorkspaceView{
167+ tab1.ID: {},
168+ tab2.ID: {},
169+ },
170+ }
171+ app.tabs.Add(tab1)
172+ app.tabs.Add(tab2)
173+
174+ // Navigation from the SECOND window should target pane-2 even though
175+ // the first window is globally focused (stale focus).
176+ err := app.navigateFromBrowserWindow(ctx, second, "https://second-window.com")
177+ require.NoError(t, err)
178+
179+ // Second window's webview must receive the navigation.
180+ assert.True(t, fakeWv2.loadURICalled, "second window webview should receive navigation")
181+ assert.Equal(t, "https://second-window.com", fakeWv2.loadURILastURI)
182+
183+ // First window's webview must NOT have been touched.
184+ assert.False(t, fakeWv1.loadURICalled, "first window webview should NOT receive navigation when second was targeted")
185+}
186+
187+// =============================================================================
188+// History sidebar toggle state tests
189+// =============================================================================
190+
191+// TestBrowserWindow_HistorySidebarToggle_NilIsNoOp verifies that when
192+// browserWindow.historySidebar is nil, toggleHistorySidebar is a safe
193+// no-op and sidebarVisible stays / remains false.
194+func TestBrowserWindow_HistorySidebarToggle_NilIsNoOp(t *testing.T) {
195+ t.Parallel()
196+
197+ bw := &browserWindow{id: "test-window", sidebarVisible: false}
198+ require.Nil(t, bw.historySidebar, "historySidebar must be nil for this test")
199+
200+ // Should not panic even though historySidebar is nil.
201+ bw.toggleHistorySidebar()
202+ assert.False(t, bw.sidebarVisible, "sidebarVisible must remain false when historySidebar is nil")
203+
204+ // Calling again also safe.
205+ bw.toggleHistorySidebar()
206+ assert.False(t, bw.sidebarVisible, "sidebarVisible must remain false on second toggle")
207+}
208+
209+// TestBrowserWindow_HistorySidebarToggle_FlipsSidebarVisible verifies that
210+// toggleHistorySidebar correctly flips sidebarVisible when the sidebar
211+// has been set. Uses a zero-value HistorySidebar (all nil-checked methods
212+// are safe to call).
213+func TestBrowserWindow_HistorySidebarToggle_FlipsSidebarVisible(t *testing.T) {
214+ t.Parallel()
215+
216+ bw := &browserWindow{
217+ id: "test-window",
218+ mainWindow: &window.MainWindow{},
219+ historySidebar: &component.HistorySidebar{},
220+ sidebarVisible: false,
221+ }
222+
223+ bw.toggleHistorySidebar()
224+ assert.True(t, bw.sidebarVisible, "sidebarVisible must be true after first toggle")
225+
226+ bw.toggleHistorySidebar()
227+ assert.False(t, bw.sidebarVisible, "sidebarVisible must be false after second toggle")
228+
229+ bw.toggleHistorySidebar()
230+ assert.True(t, bw.sidebarVisible, "sidebarVisible must be true after third toggle")
231+}
232+
233+// TestBrowserWindow_HistorySidebarShowHide_TransitionsSidebarVisible
234+// verifies that showHistorySidebar and hideHistorySidebar independently
235+// set sidebarVisible to true and false respectively.
236+func TestBrowserWindow_HistorySidebarShowHide_TransitionsSidebarVisible(t *testing.T) {
237+ t.Parallel()
238+
239+ bw := &browserWindow{
240+ id: "test-window",
241+ mainWindow: &window.MainWindow{},
242+ historySidebar: &component.HistorySidebar{},
243+ sidebarVisible: false,
244+ }
245+
246+ // Show sets visible
247+ bw.showHistorySidebar()
248+ assert.True(t, bw.sidebarVisible, "sidebarVisible must be true after show")
249+
250+ // Hide clears visible
251+ bw.hideHistorySidebar()
252+ assert.False(t, bw.sidebarVisible, "sidebarVisible must be false after hide")
253+
254+ // Redundant hide is idempotent
255+ bw.hideHistorySidebar()
256+ assert.False(t, bw.sidebarVisible, "sidebarVisible must remain false after redundant hide")
257+
258+ // Show again
259+ bw.showHistorySidebar()
260+ assert.True(t, bw.sidebarVisible, "sidebarVisible must be true after second show")
261+}
262+
263+// TestBrowserWindow_HistorySidebarShowHide_NilSidebarIsNoOp verifies that
264+// show/hide do not panic when historySidebar is nil.
265+func TestBrowserWindow_HistorySidebarShowHide_NilSidebarIsNoOp(t *testing.T) {
266+ t.Parallel()
267+
268+ bw := &browserWindow{id: "test-window"}
269+ require.Nil(t, bw.historySidebar)
270+
271+ // Should not panic even though historySidebar is nil.
272+ bw.showHistorySidebar()
273+ assert.False(t, bw.sidebarVisible, "sidebarVisible must remain false when nil")
274+
275+ bw.hideHistorySidebar()
276+ assert.False(t, bw.sidebarVisible, "sidebarVisible must remain false when nil")
277+}
278+
279+// =============================================================================
280+// App wiring: toggle handler uses lastFocusedBrowserWindow
281+// =============================================================================
282+
283+// TestApp_HistorySidebarToggleHandlerUsesLastFocusedWindow verifies that
284+// the toggle handler wired in App.wireKeyboardActions picks the
285+// lastFocusedBrowserWindow and calls toggleHistorySidebar on it.
286+func TestApp_HistorySidebarToggleHandlerUsesLastFocusedWindow(t *testing.T) {
287+ // Two windows, only the focused one has a history sidebar.
288+ focusedBW := &browserWindow{
289+ id: "focused",
290+ mainWindow: &window.MainWindow{},
291+ historySidebar: &component.HistorySidebar{},
292+ sidebarVisible: false,
293+ }
294+ otherBW := &browserWindow{
295+ id: "other",
296+ mainWindow: &window.MainWindow{},
297+ }
298+
299+ app := &App{
300+ browserWindows: map[string]*browserWindow{
301+ focusedBW.id: focusedBW,
302+ otherBW.id: otherBW,
303+ },
304+ lastFocusedWindowID: focusedBW.id,
305+ }
306+
307+ // Simulate the toggle handler that wireKeyboardActions registers:
308+ // a.kbDispatcher.SetOnToggleHistorySidebar(func(ctx context.Context) error {
309+ // bw := a.lastFocusedBrowserWindow()
310+ // ...
311+ // })
312+ bw := app.lastFocusedBrowserWindow()
313+ require.NotNil(t, bw, "lastFocusedBrowserWindow must not be nil")
314+ require.Equal(t, focusedBW.id, bw.id, "must return the focused window")
315+ require.NotNil(t, bw.historySidebar, "focused window must have a history sidebar")
316+
317+ // Toggle on the focused window.
318+ bw.toggleHistorySidebar()
319+ assert.True(t, bw.sidebarVisible, "sidebar on focused window must become visible")
320+
321+ // The other window must remain untouched.
322+ assert.False(t, otherBW.sidebarVisible, "other window sidebar must remain invisible")
323+
324+ // Toggle again: focused window sidebar hides.
325+ bw.toggleHistorySidebar()
326+ assert.False(t, bw.sidebarVisible, "sidebar on focused window must hide on second toggle")
327+}
328+
329+// TestApp_HistorySidebarToggleHandler_NilFocusedWindowIsNoOp verifies
330+// that the toggle handler is safe when lastFocusedBrowserWindow returns nil.
331+func TestApp_HistorySidebarToggleHandler_NilFocusedWindowIsNoOp(t *testing.T) {
332+ app := &App{
333+ browserWindows: make(map[string]*browserWindow),
334+ lastFocusedWindowID: "missing",
335+ }
336+
337+ // The handler should return early without error when bw is nil.
338+ bw := app.lastFocusedBrowserWindow()
339+ require.Nil(t, bw, "lastFocusedBrowserWindow should return nil for missing window")
340+}
341+
342+// =============================================================================
343+// History sidebar config callbacks: focus restoration on close
344+// =============================================================================
345+
346+// TestHistorySidebarConfig_OnCloseHidesSidebar verifies that the OnClose
347+// callback hides the sidebar for the owning browser window.
348+func TestHistorySidebarConfig_OnCloseHidesSidebar(t *testing.T) {
349+ tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
350+ bwTabs := entity.NewTabList()
351+ bwTabs.Add(tab)
352+ bwTabs.SetActive(tab.ID)
353+
354+ bw := &browserWindow{
355+ id: "window-1",
356+ tabs: bwTabs,
357+ mainWindow: &window.MainWindow{},
358+ historySidebar: &component.HistorySidebar{},
359+ sidebarVisible: true,
360+ }
361+
362+ app := &App{
363+ tabs: entity.NewTabList(),
364+ browserWindows: map[string]*browserWindow{bw.id: bw},
365+ workspaceViews: map[entity.TabID]*component.WorkspaceView{tab.ID: {}},
366+ }
367+ app.tabs.Add(tab)
368+
369+ cfg := app.buildHistorySidebarConfig(bw)
370+ cfg.OnClose()
371+
372+ assert.False(t, bw.sidebarVisible, "sidebar must be hidden by OnClose")
373+}
374+
375+// =============================================================================
376+// Dispatcher-backed Ctrl+H integration test
377+// =============================================================================
378+
379+// TestApp_HistorySidebar_ToggleThroughKeyboardDispatcher wires the real toggle
380+// handler from App.wireKeyboardActions through the KeyboardDispatcher and
381+// dispatches ActionToggleHistorySystemView, asserting the focused browser
382+// window's sidebar visibility toggles.
383+func TestApp_HistorySidebar_ToggleThroughKeyboardDispatcher(t *testing.T) {
384+ ctx := t.Context()
385+
386+ focusedBW := &browserWindow{
387+ id: "focused",
388+ mainWindow: &window.MainWindow{},
389+ historySidebar: &component.HistorySidebar{},
390+ sidebarVisible: false,
391+ }
392+ otherBW := &browserWindow{
393+ id: "other",
394+ mainWindow: &window.MainWindow{},
395+ }
396+
397+ app := &App{
398+ browserWindows: map[string]*browserWindow{
399+ focusedBW.id: focusedBW,
400+ otherBW.id: otherBW,
401+ },
402+ lastFocusedWindowID: focusedBW.id,
403+ }
404+
405+ // Create a KeyboardDispatcher and wire the production toggle handler.
406+ kbDispatcher := dispatcher.NewKeyboardDispatcher(
407+ ctx,
408+ &coordinator.WorkspaceCoordinator{},
409+ &coordinator.NavigationCoordinator{},
410+ nil, nil,
411+ dispatcher.KeyboardActions{},
412+ func(context.Context) entity.PaneID { return "" },
413+ )
414+
415+ kbDispatcher.SetOnToggleHistorySidebar(app.toggleHistorySidebarAction)
416+
417+ // First dispatch: toggle ON.
418+ err := kbDispatcher.Dispatch(ctx, input.ActionToggleHistorySystemView)
419+ require.NoError(t, err)
420+ assert.True(t, focusedBW.sidebarVisible, "focused window sidebar must be visible after toggle")
421+ assert.False(t, otherBW.sidebarVisible, "other window sidebar must remain invisible")
422+
423+ // Second dispatch: toggle OFF.
424+ err = kbDispatcher.Dispatch(ctx, input.ActionToggleHistorySystemView)
425+ require.NoError(t, err)
426+ assert.False(t, focusedBW.sidebarVisible, "focused window sidebar must be hidden after second toggle")
427+}
428+
429+// TestApp_HistorySidebar_ToggleThroughDispatcher_UnavailableReturnsError verifies that
430+// when the focused window has no history sidebar, Ctrl+H returns a clean error.
431+func TestApp_HistorySidebar_ToggleThroughDispatcher_UnavailableReturnsError(t *testing.T) {
432+ ctx := t.Context()
433+
434+ bw := &browserWindow{
435+ id: "no-sidebar",
436+ mainWindow: &window.MainWindow{},
437+ // historySidebar is nil
438+ }
439+
440+ app := &App{
441+ browserWindows: map[string]*browserWindow{bw.id: bw},
442+ lastFocusedWindowID: bw.id,
443+ }
444+
445+ kbDispatcher := dispatcher.NewKeyboardDispatcher(
446+ ctx,
447+ &coordinator.WorkspaceCoordinator{},
448+ &coordinator.NavigationCoordinator{},
449+ nil, nil,
450+ dispatcher.KeyboardActions{},
451+ func(context.Context) entity.PaneID { return "" },
452+ )
453+
454+ kbDispatcher.SetOnToggleHistorySidebar(app.toggleHistorySidebarAction)
455+
456+ err := kbDispatcher.Dispatch(ctx, input.ActionToggleHistorySystemView)
457+ require.Error(t, err)
458+ require.ErrorContains(t, err, "history sidebar unavailable")
459+ assert.False(t, bw.sidebarVisible, "sidebar must remain invisible when not wired")
460+}
461+
462+// TestApp_HistorySidebar_ToggleThroughDispatcher_NilFocusedReturnsError verifies
463+// that the toggle handler returns a clean error when there is no focused window.
464+func TestApp_HistorySidebar_ToggleThroughDispatcher_NilFocusedReturnsError(t *testing.T) {
465+ ctx := t.Context()
466+
467+ app := &App{
468+ browserWindows: make(map[string]*browserWindow),
469+ lastFocusedWindowID: "missing",
470+ }
471+
472+ kbDispatcher := dispatcher.NewKeyboardDispatcher(
473+ ctx,
474+ &coordinator.WorkspaceCoordinator{},
475+ &coordinator.NavigationCoordinator{},
476+ nil, nil,
477+ dispatcher.KeyboardActions{},
478+ func(context.Context) entity.PaneID { return "" },
479+ )
480+
481+ kbDispatcher.SetOnToggleHistorySidebar(app.toggleHistorySidebarAction)
482+
483+ err := kbDispatcher.Dispatch(ctx, input.ActionToggleHistorySystemView)
484+ require.Error(t, err)
485+ require.ErrorContains(t, err, "history sidebar unavailable")
486+}
487+
488+// =============================================================================
489+// buildHistorySidebarConfig callback seam tests
490+// =============================================================================
491+
492+// TestApp_HistorySidebarConfig_NavigateCallback verifies that the OnNavigate
493+// callback from buildHistorySidebarConfig navigates the owning browser window's
494+// active pane to the given URL.
495+func TestApp_HistorySidebarConfig_NavigateCallbackNavigates(t *testing.T) {
496+ ctx := t.Context()
497+
498+ paneID := entity.PaneID("pane-1")
499+ tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(paneID))
500+ bwTabs := entity.NewTabList()
501+ bwTabs.Add(tab)
502+ bwTabs.SetActive(tab.ID)
503+
504+ bw := &browserWindow{
505+ id: "window-1",
506+ tabs: bwTabs,
507+ mainWindow: &window.MainWindow{},
508+ historySidebar: &component.HistorySidebar{},
509+ sidebarVisible: true,
510+ }
511+
512+ fakeWv := &fakeRecordingWebView{id: 1}
513+ contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
514+ contentCoord.RegisterPopupWebView(paneID, fakeWv)
515+ navCoord := coordinator.NewNavigationCoordinator(ctx, nil, contentCoord)
516+
517+ app := &App{
518+ tabs: entity.NewTabList(),
519+ browserWindows: map[string]*browserWindow{bw.id: bw},
520+ contentCoord: contentCoord,
521+ navCoord: navCoord,
522+ workspaceViews: map[entity.TabID]*component.WorkspaceView{
523+ tab.ID: {},
524+ },
525+ }
526+ app.tabs.Add(tab)
527+
528+ // Build the config using the extracted seam.
529+ cfg := app.buildHistorySidebarConfig(bw)
530+ require.NotNil(t, cfg.OnNavigate, "OnNavigate callback must be non-nil")
531+
532+ // Invoke the OnNavigate callback.
533+ navigateURL := "https://navigated.com"
534+ err := cfg.OnNavigate(ctx, navigateURL)
535+ require.NoError(t, err)
536+
537+ // Verify the navigation reached the correct webview.
538+ assert.True(t, fakeWv.loadURICalled, "webview must receive navigation")
539+ assert.Equal(t, navigateURL, fakeWv.loadURILastURI)
540+ assert.True(t, bw.sidebarVisible, "default history navigation should keep the sidebar open")
541+}
542+
543+func TestApp_NavigateHistorySidebarSelection_KeepsSidebarVisible(t *testing.T) {
544+ ctx := t.Context()
545+
546+ paneID := entity.PaneID("pane-1")
547+ tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(paneID))
548+ bwTabs := entity.NewTabList()
549+ bwTabs.Add(tab)
550+ bwTabs.SetActive(tab.ID)
551+
552+ bw := &browserWindow{
553+ id: "window-1",
554+ tabs: bwTabs,
555+ mainWindow: &window.MainWindow{},
556+ historySidebar: &component.HistorySidebar{},
557+ sidebarVisible: true,
558+ }
559+
560+ fakeWv := &fakeRecordingWebView{id: 1}
561+ contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
562+ contentCoord.RegisterPopupWebView(paneID, fakeWv)
563+ navCoord := coordinator.NewNavigationCoordinator(ctx, nil, contentCoord)
564+
565+ app := &App{
566+ tabs: entity.NewTabList(),
567+ browserWindows: map[string]*browserWindow{bw.id: bw},
568+ contentCoord: contentCoord,
569+ navCoord: navCoord,
570+ workspaceViews: map[entity.TabID]*component.WorkspaceView{
571+ tab.ID: {},
572+ },
573+ }
574+ app.tabs.Add(tab)
575+
576+ err := app.navigateHistorySidebarSelection(ctx, bw, "https://open.com")
577+ require.NoError(t, err)
578+ assert.True(t, fakeWv.loadURICalled)
579+ assert.Equal(t, "https://open.com", fakeWv.loadURILastURI)
580+ assert.True(t, bw.sidebarVisible, "history selection navigation should not hide the sidebar")
581+}
582+
583+// TestApp_HistorySidebarConfig_NavigateCallbackOwnership verifies that
584+// OnNavigate targets the callback's owning window, not the globally focused
585+// window, when they differ.
586+func TestApp_HistorySidebarConfig_NavigateCallbackOwnership(t *testing.T) {
587+ ctx := t.Context()
588+
589+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
590+ tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
591+
592+ firstTabs := entity.NewTabList()
593+ firstTabs.Add(tab1)
594+ firstTabs.SetActive(tab1.ID)
595+ first := &browserWindow{id: "window-1", tabs: firstTabs}
596+
597+ secondTabs := entity.NewTabList()
598+ secondTabs.Add(tab2)
599+ secondTabs.SetActive(tab2.ID)
600+ second := &browserWindow{id: "window-2", tabs: secondTabs}
601+
602+ fakeWv1 := &fakeRecordingWebView{id: 1}
603+ fakeWv2 := &fakeRecordingWebView{id: 2}
604+
605+ contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
606+ contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), fakeWv1)
607+ contentCoord.RegisterPopupWebView(entity.PaneID("pane-2"), fakeWv2)
608+ navCoord := coordinator.NewNavigationCoordinator(ctx, nil, contentCoord)
609+
610+ app := &App{
611+ tabs: entity.NewTabList(),
612+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
613+ lastFocusedWindowID: first.id, // STALE: first is globally focused
614+ contentCoord: contentCoord,
615+ navCoord: navCoord,
616+ workspaceViews: map[entity.TabID]*component.WorkspaceView{
617+ tab1.ID: {},
618+ tab2.ID: {},
619+ },
620+ }
621+ app.tabs.Add(tab1)
622+ app.tabs.Add(tab2)
623+
624+ // Build config for the SECOND window, even though first is globally focused.
625+ cfg := app.buildHistorySidebarConfig(second)
626+
627+ // Invoke OnNavigate — should navigate through second window's pane-2.
628+ err := cfg.OnNavigate(ctx, "https://ownership.com")
629+ require.NoError(t, err)
630+
631+ // Second window's webview must receive navigation.
632+ assert.True(t, fakeWv2.loadURICalled, "second window webview must receive navigation")
633+ assert.Equal(t, "https://ownership.com", fakeWv2.loadURILastURI)
634+
635+ // First window's webview must NOT be touched.
636+ assert.False(t, fakeWv1.loadURICalled, "first window webview must not receive navigation")
637+}
638+
639+// TestApp_HistorySidebarConfig_KeepOpenCallback verifies that
640+// OnNavigateKeepOpen navigates the owning window without hiding the sidebar.
641+func TestApp_HistorySidebarConfig_KeepOpenCallback(t *testing.T) {
642+ ctx := t.Context()
643+
644+ paneID := entity.PaneID("pane-1")
645+ tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(paneID))
646+ bwTabs := entity.NewTabList()
647+ bwTabs.Add(tab)
648+ bwTabs.SetActive(tab.ID)
649+
650+ bw := &browserWindow{
651+ id: "window-1",
652+ tabs: bwTabs,
653+ mainWindow: &window.MainWindow{},
654+ historySidebar: &component.HistorySidebar{},
655+ sidebarVisible: true,
656+ }
657+
658+ fakeWv := &fakeRecordingWebView{id: 1}
659+ contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
660+ contentCoord.RegisterPopupWebView(paneID, fakeWv)
661+ navCoord := coordinator.NewNavigationCoordinator(ctx, nil, contentCoord)
662+
663+ app := &App{
664+ tabs: entity.NewTabList(),
665+ browserWindows: map[string]*browserWindow{bw.id: bw},
666+ contentCoord: contentCoord,
667+ navCoord: navCoord,
668+ workspaceViews: map[entity.TabID]*component.WorkspaceView{
669+ tab.ID: {},
670+ },
671+ }
672+ app.tabs.Add(tab)
673+
674+ cfg := app.buildHistorySidebarConfig(bw)
675+
676+ // OnNavigateKeepOpen navigates but does NOT close the sidebar.
677+ navigateURL := "https://keep-open.com"
678+ err := cfg.OnNavigateKeepOpen(ctx, navigateURL)
679+ require.NoError(t, err)
680+
681+ assert.True(t, fakeWv.loadURICalled, "webview must receive navigation")
682+ assert.Equal(t, navigateURL, fakeWv.loadURILastURI)
683+
684+ // Sidebar must remain visible (keep-open contract).
685+ assert.True(t, bw.sidebarVisible, "sidebar must stay visible after keep-open navigation")
686+}
687+
688+// TestApp_HistorySidebarConfig_OpenInNewPaneCallback verifies that
689+// OnOpenInNewPane activates the owning browser window and creates a split
690+// with the target URL.
691+func TestApp_HistorySidebarConfig_OpenInNewPaneCallback(t *testing.T) {
692+ ctx := t.Context()
693+
694+ paneID := entity.PaneID("pane-1")
695+ tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(paneID))
696+ bwTabs := entity.NewTabList()
697+ bwTabs.Add(tab)
698+ bwTabs.SetActive(tab.ID)
699+
700+ ws := entity.NewWorkspace("ws-1", entity.NewPane(paneID))
701+ ws.ActivePaneID = paneID
702+ tab.Workspace = ws
703+
704+ bw := &browserWindow{id: "window-1", tabs: bwTabs}
705+
706+ panesUC := usecase.NewManagePanesUseCase(func() string { return "pane-2" })
707+ wsCoord := coordinator.NewWorkspaceCoordinator(ctx, coordinator.WorkspaceCoordinatorConfig{
708+ PanesUC: panesUC,
709+ GetActiveWS: func() (*entity.Workspace, *component.WorkspaceView) {
710+ return ws, nil
711+ },
712+ })
713+
714+ app := &App{
715+ tabs: entity.NewTabList(),
716+ browserWindows: map[string]*browserWindow{bw.id: bw},
717+ wsCoord: wsCoord,
718+ workspaceViews: map[entity.TabID]*component.WorkspaceView{
719+ tab.ID: {},
720+ },
721+ }
722+ app.tabs.Add(tab)
723+
724+ cfg := app.buildHistorySidebarConfig(bw)
725+
726+ // OnOpenInNewPane should activate the owning window and split with URL.
727+ splitURL := "https://shift-enter.com"
728+ err := cfg.OnOpenInNewPane(ctx, splitURL)
729+ require.NoError(t, err)
730+
731+ // After split, workspace should have 2 panes.
732+ require.Equal(t, 2, ws.PaneCount(), "workspace should have 2 panes after split")
733+
734+ // The new pane should have the split URL.
735+ allPanes := ws.AllPanes()
736+ var newPane *entity.Pane
737+ for _, p := range allPanes {
738+ if p != nil && p.ID != paneID {
739+ newPane = p
740+ break
741+ }
742+ }
743+ require.NotNil(t, newPane, "new pane must exist after split")
744+ assert.Equal(t, splitURL, newPane.URI, "new pane must have the split URL")
745+}
746+
747+// TestApp_HistorySidebarConfig_CloseCallback verifies that OnClose hides the
748+// sidebar for the owning browser window and restores focus to the active pane.
749+func TestApp_HistorySidebarConfig_CloseCallback(t *testing.T) {
750+ paneID := entity.PaneID("pane-1")
751+ tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(paneID))
752+ bwTabs := entity.NewTabList()
753+ bwTabs.Add(tab)
754+ bwTabs.SetActive(tab.ID)
755+
756+ ws := entity.NewWorkspace("ws-1", entity.NewPane(paneID))
757+ ws.ActivePaneID = paneID
758+ tab.Workspace = ws
759+
760+ bw := &browserWindow{
761+ id: "window-1",
762+ tabs: bwTabs,
763+ mainWindow: &window.MainWindow{},
764+ historySidebar: &component.HistorySidebar{},
765+ sidebarVisible: true,
766+ }
767+
768+ // Create a minimal workspace view.
769+ wsView := &component.WorkspaceView{}
770+ // We set up the app so that hideAndRestoreFocusForBrowserWindow
771+ // can find the wsView and call FocusPane on it.
772+ // Since FocusPane is a method on WorkspaceView that requires GTK,
773+ // we verify the state changes that happen before FocusPane:
774+ // sidebarVisible must be toggled to false.
775+
776+ app := &App{
777+ tabs: entity.NewTabList(),
778+ browserWindows: map[string]*browserWindow{bw.id: bw},
779+ workspaceViews: map[entity.TabID]*component.WorkspaceView{
780+ tab.ID: wsView,
781+ },
782+ }
783+ cfg := app.buildHistorySidebarConfig(bw)
784+
785+ // OnClose hides the sidebar.
786+ cfg.OnClose()
787+
788+ assert.False(t, bw.sidebarVisible, "sidebar must be hidden after close")
789+}
790+
791+// TestApp_HistorySidebarConfig_CloseWithNoSidebarIsSafe verifies that
792+// OnClose (hideAndRestoreFocusForBrowserWindow) is safe when the browser
793+// window has no sidebar or is nil.
794+func TestApp_HistorySidebarConfig_CloseWithNoSidebarIsSafe(t *testing.T) {
795+ bw := &browserWindow{
796+ id: "no-sidebar",
797+ mainWindow: &window.MainWindow{},
798+ // historySidebar is nil
799+ }
800+
801+ app := &App{
802+ tabs: entity.NewTabList(),
803+ browserWindows: map[string]*browserWindow{bw.id: bw},
804+ }
805+
806+ cfg := app.buildHistorySidebarConfig(bw)
807+
808+ // Should not panic even with nil sidebar.
809+ require.NotPanics(t, func() { cfg.OnClose() })
810+}
811+
812+// TestApp_HideAndRestoreFocusForBrowserWindow_HidesSidebar verifies that
813+// hideAndRestoreFocusForBrowserWindow hides the sidebar.
814+func TestApp_HideAndRestoreFocusForBrowserWindow_HidesSidebar(t *testing.T) {
815+ paneID := entity.PaneID("pane-1")
816+ tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(paneID))
817+ bwTabs := entity.NewTabList()
818+ bwTabs.Add(tab)
819+ bwTabs.SetActive(tab.ID)
820+
821+ ws := entity.NewWorkspace("ws-1", entity.NewPane(paneID))
822+ ws.ActivePaneID = paneID
823+ tab.Workspace = ws
824+
825+ bw := &browserWindow{
826+ id: "window-1",
827+ tabs: bwTabs,
828+ mainWindow: &window.MainWindow{},
829+ historySidebar: &component.HistorySidebar{},
830+ sidebarVisible: true,
831+ }
832+
833+ wsView := &component.WorkspaceView{}
834+
835+ app := &App{
836+ tabs: entity.NewTabList(),
837+ browserWindows: map[string]*browserWindow{bw.id: bw},
838+ workspaceViews: map[entity.TabID]*component.WorkspaceView{
839+ tab.ID: wsView,
840+ },
841+ }
842+ app.tabs.Add(tab)
843+
844+ // Call hideAndRestoreFocusForBrowserWindow directly.
845+ app.hideAndRestoreFocusForBrowserWindow(bw)
846+
847+ // Sidebar must be hidden.
848+ assert.False(t, bw.sidebarVisible, "sidebar must be hidden")
849+}
850+
851+// TestApp_HideAndRestoreFocusForBrowserWindow_NilBWIsSafe verifies that
852+// hideAndRestoreFocusForBrowserWindow handles nil browser window safely.
853+func TestApp_HideAndRestoreFocusForBrowserWindow_NilBWIsSafe(t *testing.T) {
854+ app := &App{}
855+ require.NotPanics(t, func() { app.hideAndRestoreFocusForBrowserWindow(nil) })
856+}
857+
858+// =============================================================================
859+// Sidebar width config tests
860+// =============================================================================
861+
862+func TestHistorySidebarWidthConfig_ConfigValue(t *testing.T) {
863+ cfg := historySidebarWidthConfig(350)
864+ assert.Equal(t, 350, cfg.WidthPx, "should apply config-backed width of 350px")
865+ assert.Equal(t, 280, cfg.MinPx, "should keep default min clamp")
866+ assert.Equal(t, 380, cfg.MaxPx, "should keep default max clamp")
867+}
868+
869+func TestHistorySidebarWidthConfig_DefaultValue(t *testing.T) {
870+ cfg := historySidebarWidthConfig(0)
871+ assert.Equal(t, 320, cfg.WidthPx, "should use default width of 320px when config is 0")
872+ assert.Equal(t, 280, cfg.MinPx, "should keep default min clamp")
873+ assert.Equal(t, 380, cfg.MaxPx, "should keep default max clamp")
874+}
875+
876+// TestBrowserWindow_ApplySidebarWidthConfig_NilMainWindowIsSafe verifies
877+// that applySidebarWidthConfig handles nil mainWindow without panic.
878+func TestBrowserWindow_ApplySidebarWidthConfig_NilMainWindowIsSafe(t *testing.T) {
879+ bw := &browserWindow{mainWindow: nil}
880+ app := &App{deps: &Dependencies{Config: &config.Config{SidebarWidth: 300}}}
881+ require.NotPanics(t, func() { bw.applySidebarWidthConfig(app) })
882+}
883+
884+// TestBrowserWindow_ApplySidebarWidthConfig_NilDepsIsSafe verifies
885+// that applySidebarWidthConfig handles nil deps without panic.
886+func TestBrowserWindow_ApplySidebarWidthConfig_NilDepsIsSafe(t *testing.T) {
887+ mw := &window.MainWindow{}
888+ bw := &browserWindow{mainWindow: mw}
889+ app := &App{deps: nil}
890+ require.NotPanics(t, func() { bw.applySidebarWidthConfig(app) })
891+}
M internal/ui/browser_window_test.go +2 -0
@@ -43,6 +43,7 @@ func TestBrowserWindow_RemoveBrowserWindowClearsShellState(t *testing.T) {
43 setShellField(t, removed, "globalShortcutHandler", &input.GlobalShortcutHandler{})43 setShellField(t, removed, "globalShortcutHandler", &input.GlobalShortcutHandler{})
44 setShellField(t, removed, "permissionDialog", (*testPermissionDialogPresenter)(nil))44 setShellField(t, removed, "permissionDialog", (*testPermissionDialogPresenter)(nil))
45 setShellField(t, removed, "webrtcIndicator", &component.WebRTCPermissionIndicator{})45 setShellField(t, removed, "webrtcIndicator", &component.WebRTCPermissionIndicator{})
46+ setShellField(t, removed, "historySidebar", &component.HistorySidebar{})
46 47
47 app.removeBrowserWindow(removed.id)48 app.removeBrowserWindow(removed.id)
48 49
@@ -60,6 +61,7 @@ func TestBrowserWindow_RemoveBrowserWindowClearsShellState(t *testing.T) {
60 "globalShortcutHandler",61 "globalShortcutHandler",
61 "permissionDialog",62 "permissionDialog",
62 "webrtcIndicator",63 "webrtcIndicator",
64+ "historySidebar",
63 } {65 } {
64 if !fieldIsZero(t, removed, name) {66 if !fieldIsZero(t, removed, name) {
65 t.Fatalf("browserWindow.%s was not cleared", name)67 t.Fatalf("browserWindow.%s was not cleared", name)
A internal/ui/component/history_model.go +304 -0
@@ -0,0 +1,304 @@
1+// Package component provides UI components for the browser.
2+package component
3+
4+import (
5+ "sort"
6+ "strconv"
7+ "strings"
8+ "time"
9+
10+ "github.com/bnema/dumber/internal/domain/entity"
11+)
12+
13+// historyGroup represents a day-grouped section of history entries.
14+// Groups are ordered newest-first; entries within a group are newest-first.
15+type historyGroup struct {
16+ Label string
17+ Entries []*entity.HistoryEntry
18+}
19+
20+// dayKey identifies a unique calendar day for grouping.
21+type dayKey struct {
22+ year int
23+ month time.Month
24+ day int
25+}
26+
27+const (
28+ dayLabelToday = "Today"
29+ dayLabelYesterday = "Yesterday"
30+ dayLabelOtherYearFormat = "January 2, 2006"
31+ hoursPerDay = 24
32+)
33+
34+// groupHistoryByDay groups entries by calendar day, newest-first.
35+// Days are labeled: "Today", "Yesterday", "Monday, January 2" (current year),
36+// "January 2, 2006" (other years). Within each group entries remain in
37+// the order they arrived (assumed most-recent-first).
38+func groupHistoryByDay(entries []*entity.HistoryEntry) []historyGroup {
39+ if len(entries) == 0 {
40+ return nil
41+ }
42+
43+ now := time.Now()
44+ local := now.Location()
45+ todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, local)
46+
47+ groupsMap := make(map[dayKey]*historyGroup)
48+ var keys []dayKey
49+
50+ for _, entry := range entries {
51+ t := entry.LastVisited.In(local)
52+ key := dayKey{t.Year(), t.Month(), t.Day()}
53+
54+ if _, ok := groupsMap[key]; !ok {
55+ label := dayLabelForKey(key, todayStart, now)
56+ groupsMap[key] = &historyGroup{Label: label}
57+ keys = append(keys, key)
58+ }
59+ groupsMap[key].Entries = append(groupsMap[key].Entries, entry)
60+ }
61+
62+ // Sort keys newest-first
63+ sort.Slice(keys, func(i, j int) bool {
64+ ki, kj := keys[i], keys[j]
65+ ti := time.Date(ki.year, ki.month, ki.day, 0, 0, 0, 0, local)
66+ tj := time.Date(kj.year, kj.month, kj.day, 0, 0, 0, 0, local)
67+ return ti.After(tj)
68+ })
69+
70+ groups := make([]historyGroup, len(keys))
71+ for i, k := range keys {
72+ groups[i] = *groupsMap[k]
73+ }
74+ return groups
75+}
76+
77+func dayLabelForKey(key dayKey, todayStart, now time.Time) string {
78+ dayStart := time.Date(key.year, key.month, key.day, 0, 0, 0, 0, now.Location())
79+ switch {
80+ case dayStart.Equal(todayStart):
81+ return dayLabelToday
82+ case dayStart.Equal(todayStart.AddDate(0, 0, -1)):
83+ return dayLabelYesterday
84+ case key.year == now.Year():
85+ return dayStart.Format("Monday, January 2")
86+ default:
87+ return dayStart.Format(dayLabelOtherYearFormat)
88+ }
89+}
90+
91+// readableURL strips the protocol prefix and optional www. for display.
92+func readableURL(rawURL string) string {
93+ u := rawURL
94+ for _, prefix := range []string{"https://", "http://", "ftp://"} {
95+ if strings.HasPrefix(u, prefix) {
96+ u = u[len(prefix):]
97+ break
98+ }
99+ }
100+ u = strings.TrimPrefix(u, "www.")
101+ // Remove trailing slash when there's no path beyond it
102+ if slashIdx := strings.IndexByte(u, '/'); slashIdx == -1 || slashIdx == len(u)-1 {
103+ u = strings.TrimSuffix(u, "/")
104+ }
105+ return u
106+}
107+
108+// relativeTime returns a compact relative time label for a timestamp.
109+// Examples: "2m ago", "1h ago", "3d ago", "Jan 2".
110+func relativeTime(t time.Time) string {
111+ now := time.Now()
112+ d := now.Sub(t)
113+
114+ switch {
115+ case d < time.Minute:
116+ return "now"
117+ case d < time.Hour:
118+ m := int(d.Minutes())
119+ if m < 2 {
120+ return "1m ago"
121+ }
122+ return strconv.Itoa(m) + "m ago"
123+ case d < hoursPerDay*time.Hour:
124+ h := int(d.Hours())
125+ if h < 2 {
126+ return "1h ago"
127+ }
128+ return strconv.Itoa(h) + "h ago"
129+ case d < 7*hoursPerDay*time.Hour:
130+ days := int(d.Hours() / hoursPerDay)
131+ if days < 2 {
132+ return "1d ago"
133+ }
134+ return strconv.Itoa(days) + "d ago"
135+ default:
136+ if t.Year() == now.Year() {
137+ return t.Format("Jan 2")
138+ }
139+ return t.Format("Jan 2, 2006")
140+ }
141+}
142+
143+// historyDisplayRowKind identifies the type of row rendered in the sidebar.
144+type historyDisplayRowKind int
145+
146+const (
147+ historyDisplayRowHeader historyDisplayRowKind = iota
148+ historyDisplayRowEntry
149+)
150+
151+// historyDisplayRow is the explicit row model rendered by the history sidebar.
152+// It is the only source used for row lookup, selection, activation, and delete.
153+type historyDisplayRow struct {
154+ Kind historyDisplayRowKind
155+ Label string
156+ Entry *entity.HistoryEntry
157+ GroupIndex int
158+}
159+
160+func buildHistoryDisplayRows(groups []historyGroup) []historyDisplayRow {
161+ if len(groups) == 0 {
162+ return nil
163+ }
164+ rows := make([]historyDisplayRow, 0)
165+ for gi, group := range groups {
166+ rows = append(rows, historyDisplayRow{Kind: historyDisplayRowHeader, Label: group.Label, GroupIndex: gi})
167+ for _, entry := range group.Entries {
168+ rows = append(rows, historyDisplayRow{Kind: historyDisplayRowEntry, Entry: entry, GroupIndex: gi})
169+ }
170+ }
171+ return rows
172+}
173+
174+// keyboardNavModel provides pure functions for keyboard navigation over the
175+// explicit display rows. It has no GTK dependencies and can be tested directly.
176+type keyboardNavModel struct {
177+ rows []historyDisplayRow
178+}
179+
180+// newKeyboardNavModel creates a keyboardNavModel over day-grouped history.
181+func newKeyboardNavModel(groups []historyGroup) keyboardNavModel {
182+ return newKeyboardNavModelFromRows(buildHistoryDisplayRows(groups))
183+}
184+
185+func newKeyboardNavModelFromRows(rows []historyDisplayRow) keyboardNavModel {
186+ return keyboardNavModel{rows: rows}
187+}
188+
189+// totalRows returns the number of explicit display rows.
190+func (m keyboardNavModel) totalRows() int { return len(m.rows) }
191+
192+// isSelectable returns true when the display row corresponds to an entry.
193+func (m keyboardNavModel) isSelectable(index int) bool {
194+ return index >= 0 && index < len(m.rows) && m.rows[index].Kind == historyDisplayRowEntry && m.rows[index].Entry != nil
195+}
196+
197+func (m keyboardNavModel) firstSelectableIndex() int {
198+ for i := 0; i < m.totalRows(); i++ {
199+ if m.isSelectable(i) {
200+ return i
201+ }
202+ }
203+ return -1
204+}
205+
206+func (m keyboardNavModel) lastSelectableIndex() int {
207+ for i := m.totalRows() - 1; i >= 0; i-- {
208+ if m.isSelectable(i) {
209+ return i
210+ }
211+ }
212+ return -1
213+}
214+
215+func (m keyboardNavModel) nextSelectableIndex(from, dir int) int {
216+ if dir != -1 && dir != +1 {
217+ return -1
218+ }
219+ for i := from + dir; i >= 0 && i < m.totalRows(); i += dir {
220+ if m.isSelectable(i) {
221+ return i
222+ }
223+ }
224+ return -1
225+}
226+
227+func (m keyboardNavModel) groupIndexAt(index int) int {
228+ if index < 0 || index >= len(m.rows) {
229+ return -1
230+ }
231+ return m.rows[index].GroupIndex
232+}
233+
234+func (m keyboardNavModel) maxGroupIndex() int {
235+ maxIndex := -1
236+ for _, row := range m.rows {
237+ if row.GroupIndex > maxIndex {
238+ maxIndex = row.GroupIndex
239+ }
240+ }
241+ return maxIndex
242+}
243+
244+func (m keyboardNavModel) cumulativeOffsetAtGroup(gi int) int {
245+ if gi < 0 {
246+ return -1
247+ }
248+ for i, row := range m.rows {
249+ if row.GroupIndex == gi && row.Kind == historyDisplayRowHeader {
250+ return i
251+ }
252+ }
253+ return -1
254+}
255+
256+func (m keyboardNavModel) firstEntryOfGroup(gi int) int {
257+ for i, row := range m.rows {
258+ if row.GroupIndex == gi && row.Kind == historyDisplayRowEntry && row.Entry != nil {
259+ return i
260+ }
261+ }
262+ return -1
263+}
264+
265+func (m keyboardNavModel) previousDayBoundary(from int) int {
266+ gi := m.groupIndexAt(from)
267+ if gi <= 0 {
268+ return -1
269+ }
270+ return m.firstEntryOfGroup(gi - 1)
271+}
272+
273+func (m keyboardNavModel) nextDayBoundary(from int) int {
274+ gi := m.groupIndexAt(from)
275+ if gi < 0 || gi >= m.maxGroupIndex() {
276+ return -1
277+ }
278+ return m.firstEntryOfGroup(gi + 1)
279+}
280+
281+func (m keyboardNavModel) entryAt(index int) *entity.HistoryEntry {
282+ if !m.isSelectable(index) {
283+ return nil
284+ }
285+ return m.rows[index].Entry
286+}
287+
288+func (m keyboardNavModel) entryURLAt(index int) string {
289+ e := m.entryAt(index)
290+ if e == nil {
291+ return ""
292+ }
293+ return e.URL
294+}
295+
296+func (m keyboardNavModel) entryCount() int {
297+ n := 0
298+ for i := range m.rows {
299+ if m.isSelectable(i) {
300+ n++
301+ }
302+ }
303+ return n
304+}
A internal/ui/component/history_model_test.go +741 -0
@@ -0,0 +1,741 @@
1+package component
2+
3+import (
4+ "fmt"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestGroupHistoryByDay_Empty(t *testing.T) {
14+ assert.Nil(t, groupHistoryByDay(nil))
15+ assert.Nil(t, groupHistoryByDay([]*entity.HistoryEntry{}))
16+}
17+
18+func TestGroupHistoryByDay_SingleEntry(t *testing.T) {
19+ now := time.Now()
20+ entries := []*entity.HistoryEntry{
21+ {ID: 1, URL: "https://example.com", Title: "Example", LastVisited: now},
22+ }
23+ groups := groupHistoryByDay(entries)
24+ require.Len(t, groups, 1)
25+ assert.Equal(t, "Today", groups[0].Label)
26+ assert.Len(t, groups[0].Entries, 1)
27+}
28+
29+func TestGroupHistoryByDay_TodayYesterdayOlder(t *testing.T) {
30+ now := time.Now()
31+ today := now
32+ yesterday := now.AddDate(0, 0, -1)
33+ older := now.AddDate(0, 0, -5)
34+
35+ entries := []*entity.HistoryEntry{
36+ {ID: 1, URL: "https://older.com", Title: "Older", LastVisited: older},
37+ {ID: 2, URL: "https://yesterday.com", Title: "Yesterday", LastVisited: yesterday},
38+ {ID: 3, URL: "https://today.com", Title: "Today", LastVisited: today},
39+ }
40+ groups := groupHistoryByDay(entries)
41+ require.Len(t, groups, 3)
42+ assert.Equal(t, "Today", groups[0].Label)
43+ assert.Equal(t, "Yesterday", groups[1].Label)
44+ assert.NotEmpty(t, groups[2].Label, "older entry should have a formatted date label")
45+ assert.Len(t, groups[2].Entries, 1)
46+}
47+
48+func TestGroupHistoryByDay_SameDayEntriesGrouped(t *testing.T) {
49+ now := time.Now()
50+ entries := []*entity.HistoryEntry{
51+ {ID: 1, URL: "https://a.com", Title: "A", LastVisited: now},
52+ {ID: 2, URL: "https://b.com", Title: "B", LastVisited: now.Add(-time.Hour)},
53+ }
54+ groups := groupHistoryByDay(entries)
55+ require.Len(t, groups, 1)
56+ assert.Equal(t, "Today", groups[0].Label)
57+ assert.Len(t, groups[0].Entries, 2)
58+}
59+
60+func TestReadableURL_StripsProtocol(t *testing.T) {
61+ assert.Equal(t, "example.com", readableURL("https://example.com"))
62+ assert.Equal(t, "example.com/page", readableURL("http://example.com/page"))
63+ assert.Equal(t, "example.com", readableURL("https://www.example.com"))
64+}
65+
66+func TestReadableURL_KeepsPath(t *testing.T) {
67+ assert.Equal(t, "example.com/path/to/page", readableURL("https://example.com/path/to/page"))
68+}
69+
70+func TestRelativeTime_Now(t *testing.T) {
71+ assert.Equal(t, "now", relativeTime(time.Now()))
72+}
73+
74+func TestRelativeTime_Minutes(t *testing.T) {
75+ assert.Equal(t, "1m ago", relativeTime(time.Now().Add(-1*time.Minute)))
76+ assert.Equal(t, "5m ago", relativeTime(time.Now().Add(-5*time.Minute)))
77+}
78+
79+func TestRelativeTime_Hours(t *testing.T) {
80+ assert.Equal(t, "1h ago", relativeTime(time.Now().Add(-1*time.Hour)))
81+ assert.Equal(t, "3h ago", relativeTime(time.Now().Add(-3*time.Hour)))
82+}
83+
84+func TestRelativeTime_Days(t *testing.T) {
85+ assert.Equal(t, "1d ago", relativeTime(time.Now().Add(-25*time.Hour)))
86+ assert.Equal(t, "3d ago", relativeTime(time.Now().Add(-72*time.Hour)))
87+}
88+
89+func TestGroupHistoryByDay_CrossYearDifferentLabels(t *testing.T) {
90+ now := time.Now()
91+ thisYear := now
92+ lastYear := now.AddDate(-1, 0, 0)
93+ twoYearsAgo := now.AddDate(-2, 0, 0)
94+
95+ entries := []*entity.HistoryEntry{
96+ {ID: 1, URL: "https://two-years-ago.com", Title: "Two Years Ago", LastVisited: twoYearsAgo},
97+ {ID: 2, URL: "https://last-year.com", Title: "Last Year", LastVisited: lastYear},
98+ {ID: 3, URL: "https://this-year.com", Title: "This Year", LastVisited: thisYear},
99+ }
100+ groups := groupHistoryByDay(entries)
101+ require.Len(t, groups, 3)
102+ assert.Equal(t, "Today", groups[0].Label)
103+ assert.Equal(t, lastYear.Format(dayLabelOtherYearFormat), groups[1].Label)
104+ assert.Equal(t, twoYearsAgo.Format(dayLabelOtherYearFormat), groups[2].Label)
105+ assert.Len(t, groups[2].Entries, 1)
106+}
107+
108+func TestGroupHistoryByDay_MaintainsInputOrderWithinDay(t *testing.T) {
109+ now := time.Now()
110+ entries := []*entity.HistoryEntry{
111+ {ID: 1, URL: "https://first.com", Title: "First", LastVisited: now},
112+ {ID: 2, URL: "https://second.com", Title: "Second", LastVisited: now.Add(-30 * time.Minute)},
113+ {ID: 3, URL: "https://third.com", Title: "Third", LastVisited: now.Add(-2 * time.Hour)},
114+ }
115+ groups := groupHistoryByDay(entries)
116+ require.Len(t, groups, 1)
117+ require.Len(t, groups[0].Entries, 3)
118+ // Entries within the same day maintain input order
119+ assert.Equal(t, "https://first.com", groups[0].Entries[0].URL)
120+ assert.Equal(t, "https://second.com", groups[0].Entries[1].URL)
121+ assert.Equal(t, "https://third.com", groups[0].Entries[2].URL)
122+}
123+
124+func TestGroupHistoryByDay_LeapYearBoundary(t *testing.T) {
125+ // Two consecutive days in a leap year should produce separate groups.
126+ // Use local noon on a known leap year to avoid UTC-to-local date
127+ // rollover, and check that labels include the year when not the
128+ // current year.
129+ loc := time.Now().Location()
130+ leapYear := 2024
131+ feb28 := time.Date(leapYear, time.February, 28, 12, 0, 0, 0, loc)
132+ feb29 := time.Date(leapYear, time.February, 29, 12, 0, 0, 0, loc)
133+
134+ entries := []*entity.HistoryEntry{
135+ {ID: 1, URL: "https://feb29.com", Title: "Feb 29", LastVisited: feb29},
136+ {ID: 2, URL: "https://feb28.com", Title: "Feb 28", LastVisited: feb28},
137+ }
138+ groups := groupHistoryByDay(entries)
139+ require.Len(t, groups, 2, "Feb 28 and Feb 29 should be in separate groups")
140+ // Verify order: newest first
141+ // Since 2024 is likely not the current year, labels include year
142+ assert.Equal(t, feb29.Format("January 2, 2006"), groups[0].Label)
143+ assert.Equal(t, feb28.Format("January 2, 2006"), groups[1].Label)
144+}
145+
146+func TestGroupHistoryByDay_SameLocalDay(t *testing.T) {
147+ // Two entries on the same calendar day in local timezone should be grouped.
148+ loc := time.Now().Location()
149+ sameDay := time.Date(2026, time.June, 10, 12, 0, 0, 0, loc)
150+ earlier := time.Date(2026, time.June, 10, 8, 0, 0, 0, loc)
151+
152+ entries := []*entity.HistoryEntry{
153+ {ID: 1, URL: "https://later.com", Title: "Later", LastVisited: sameDay},
154+ {ID: 2, URL: "https://earlier.com", Title: "Earlier", LastVisited: earlier},
155+ }
156+ groups := groupHistoryByDay(entries)
157+ require.Len(t, groups, 1, "entries on same calendar day should be in one group")
158+ assert.Contains(t, groups[0].Label, "June")
159+}
160+
161+func TestReadableURL_StripsFTPAndWWW(t *testing.T) {
162+ assert.Equal(t, "example.com", readableURL("ftp://example.com"))
163+ assert.Equal(t, "example.com", readableURL("http://www.example.com"))
164+ assert.Equal(t, "example.com", readableURL("https://www.example.com/"))
165+}
166+
167+func TestReadableURL_NoProtocol(t *testing.T) {
168+ assert.Equal(t, "example.com", readableURL("example.com"))
169+}
170+
171+func TestReadableURL_TrailingSlash(t *testing.T) {
172+ assert.Equal(t, "example.com", readableURL("https://example.com/"))
173+ assert.Equal(t, "example.com/path/", readableURL("https://example.com/path/"))
174+}
175+
176+func TestReadableURL_EmptyOrRoot(t *testing.T) {
177+ assert.Empty(t, readableURL(""))
178+ assert.Empty(t, readableURL("/"))
179+}
180+
181+func TestReadableURL_PreservesPort(t *testing.T) {
182+ assert.Equal(t, "example.com:8080", readableURL("https://example.com:8080"))
183+ assert.Equal(t, "example.com:3000/path", readableURL("https://example.com:3000/path"))
184+}
185+
186+func TestRelativeTime_BoundaryEdgeCases(t *testing.T) {
187+ // Just under 1 minute → "now"
188+ assert.Equal(t, "now", relativeTime(time.Now().Add(-30*time.Second)))
189+ // 59 seconds → "now"
190+ assert.Equal(t, "now", relativeTime(time.Now().Add(-59*time.Second)))
191+ // 59 minutes 59 seconds → "59m ago"
192+ assert.Equal(t, "59m ago", relativeTime(time.Now().Add(-59*time.Minute).Add(-59*time.Second)))
193+ // 23 hours 59 minutes → "23h ago"
194+ assert.Equal(t, "23h ago", relativeTime(time.Now().Add(-23*time.Hour).Add(-59*time.Minute)))
195+ // 6 days 23 hours → "6d ago"
196+ assert.Equal(t, "6d ago", relativeTime(time.Now().Add(-6*24*time.Hour).Add(-23*time.Hour)))
197+ // 7 days → "Jul 8" format (changes by date, just verify not in "Xd ago")
198+ result := relativeTime(time.Now().Add(-7 * 24 * time.Hour))
199+ assert.NotContains(t, result, "d ago", "7+ days should not use day format")
200+}
201+
202+func TestRelativeTime_Future(t *testing.T) {
203+ future := time.Now().Add(time.Hour)
204+ result := relativeTime(future)
205+ // Current implementation returns "now" for negative durations.
206+ // This is a known limitation; the test documents the behavior.
207+ assert.Equal(t, "now", result, "future times currently reported as 'now' (known limitation)")
208+}
209+
210+func TestRelativeTime_DifferentYear(t *testing.T) {
211+ // An entry from a previous year should show a date with month abbreviation
212+ // and year when not current year.
213+ lastYear := time.Now().AddDate(-1, -1, 0)
214+ result := relativeTime(lastYear)
215+ _, err := time.Parse("Jan 2, 2006", result)
216+ assert.NoError(t, err, "old entry should use the Jan 2, 2006 layout, got %q", result)
217+}
218+
219+func TestDayLabelForKey_MultiYearAgo(t *testing.T) {
220+ // Produce a label for a dayKey that is multiple years ago
221+ now := time.Now()
222+ todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
223+ // Two years ago, same month/day
224+ past := now.AddDate(-2, 0, -1)
225+ key := dayKey{past.Year(), past.Month(), past.Day()}
226+ label := dayLabelForKey(key, todayStart, now)
227+ assert.Contains(t, label, past.Format("2006"), "multi-year-old day label should include year")
228+}
229+
230+func TestDayLabelForKey_WithinCurrentYear(t *testing.T) {
231+ now := time.Now()
232+ todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
233+ // Earlier this month
234+ past := now.AddDate(0, 0, -10)
235+ key := dayKey{past.Year(), past.Month(), past.Day()}
236+ label := dayLabelForKey(key, todayStart, now)
237+ // Should be weekday format without year
238+ assert.NotContains(t, label, "2006", "within-year label should not include year")
239+ assert.Contains(t, label, past.Weekday().String(), "within-year label should include weekday")
240+}
241+
242+// =============================================================================
243+// keyboardNavModel tests
244+// =============================================================================
245+
246+func makeGroups(dayCounts ...int) []historyGroup {
247+ now := time.Now()
248+ groups := make([]historyGroup, len(dayCounts))
249+ for i, count := range dayCounts {
250+ entries := make([]*entity.HistoryEntry, count)
251+ for j := 0; j < count; j++ {
252+ entries[j] = &entity.HistoryEntry{
253+ ID: int64(i*100 + j),
254+ URL: fmt.Sprintf("https://entry-%d-%d.com", i, j),
255+ Title: fmt.Sprintf("Entry %d-%d", i, j),
256+ LastVisited: now.Add(-time.Duration(i) * 24 * time.Hour).Add(-time.Duration(j) * time.Minute),
257+ }
258+ }
259+ groups[i] = historyGroup{
260+ Label: fmt.Sprintf("Day %d", i),
261+ Entries: entries,
262+ }
263+ }
264+ return groups
265+}
266+
267+func TestKeyboardNavModel_EmptyGroups(t *testing.T) {
268+ m := newKeyboardNavModel(nil)
269+ assert.Equal(t, 0, m.totalRows())
270+ assert.Equal(t, -1, m.firstSelectableIndex())
271+ assert.Equal(t, -1, m.lastSelectableIndex())
272+ assert.Nil(t, m.entryAt(0))
273+ assert.Empty(t, m.entryURLAt(0))
274+
275+ m = newKeyboardNavModel([]historyGroup{})
276+ assert.Equal(t, 0, m.totalRows())
277+}
278+
279+func TestKeyboardNavModel_SingleGroup(t *testing.T) {
280+ groups := makeGroups(3) // 1 header + 3 entries = 4 rows
281+ m := newKeyboardNavModel(groups)
282+
283+ assert.Equal(t, 4, m.totalRows())
284+
285+ // Row 0: header (not selectable)
286+ assert.False(t, m.isSelectable(0))
287+ assert.Nil(t, m.entryAt(0))
288+ assert.Empty(t, m.entryURLAt(0))
289+
290+ // Rows 1-3: entries (selectable)
291+ for i := 1; i <= 3; i++ {
292+ assert.True(t, m.isSelectable(i), "row %d should be selectable", i)
293+ assert.NotNil(t, m.entryAt(i))
294+ assert.Contains(t, m.entryURLAt(i), "entry-0")
295+ }
296+
297+ // first / last
298+ assert.Equal(t, 1, m.firstSelectableIndex())
299+ assert.Equal(t, 3, m.lastSelectableIndex())
300+}
301+
302+func TestKeyboardNavModel_MultipleGroups(t *testing.T) {
303+ groups := makeGroups(2, 3) // group0: header+e0+e1, group1: header+e0+e1+e2
304+ m := newKeyboardNavModel(groups)
305+
306+ // Layout: [H0, E0-0, E0-1, H1, E1-0, E1-1, E1-2] = 7 rows
307+ assert.Equal(t, 7, m.totalRows())
308+
309+ // Selectable: rows 1,2,4,5,6
310+ assert.False(t, m.isSelectable(0)) // H0
311+ assert.True(t, m.isSelectable(1)) // E0-0
312+ assert.True(t, m.isSelectable(2)) // E0-1
313+ assert.False(t, m.isSelectable(3)) // H1
314+ assert.True(t, m.isSelectable(4)) // E1-0
315+ assert.True(t, m.isSelectable(5)) // E1-1
316+ assert.True(t, m.isSelectable(6)) // E1-2
317+
318+ assert.Equal(t, 1, m.firstSelectableIndex())
319+ assert.Equal(t, 6, m.lastSelectableIndex())
320+}
321+
322+func TestKeyboardNavModel_NextPreviousSelectable(t *testing.T) {
323+ groups := makeGroups(2, 2) // rows: H0(0), E0-0(1), E0-1(2), H1(3), E1-0(4), E1-1(5)
324+ m := newKeyboardNavModel(groups)
325+
326+ // Next from 0 (header) → 1
327+ assert.Equal(t, 1, m.nextSelectableIndex(0, +1))
328+ // Next from 1 → 2
329+ assert.Equal(t, 2, m.nextSelectableIndex(1, +1))
330+ // Next from 2 → 4 (skip header H1 at 3)
331+ assert.Equal(t, 4, m.nextSelectableIndex(2, +1))
332+ // Next from 5 → -1 (end)
333+ assert.Equal(t, -1, m.nextSelectableIndex(5, +1))
334+
335+ // Previous from 5 (E1-1) → 4
336+ assert.Equal(t, 4, m.nextSelectableIndex(5, -1))
337+ // Previous from 4 → 2 (skip header H1 at 3)
338+ assert.Equal(t, 2, m.nextSelectableIndex(4, -1))
339+ // Previous from 1 → -1 (beginning, only header before)
340+ assert.Equal(t, -1, m.nextSelectableIndex(1, -1))
341+ // Previous from 0 (header) → -1
342+ assert.Equal(t, -1, m.nextSelectableIndex(0, -1))
343+}
344+
345+func TestKeyboardNavModel_InvalidDirection(t *testing.T) {
346+ groups := makeGroups(2)
347+ m := newKeyboardNavModel(groups)
348+ assert.Equal(t, -1, m.nextSelectableIndex(0, 0))
349+ assert.Equal(t, -1, m.nextSelectableIndex(0, 2))
350+ assert.Equal(t, -1, m.nextSelectableIndex(0, -2))
351+}
352+
353+func TestKeyboardNavModel_DayBoundaries(t *testing.T) {
354+ groups := makeGroups(2, 1, 2)
355+ // Layout: H0(0), E0-0(1), E0-1(2), H1(3), E1-0(4), H2(5), E2-0(6), E2-1(7)
356+ m := newKeyboardNavModel(groups)
357+
358+ // Previous day from E0-1(2) → E0-1 is in group 0, no previous day.
359+ assert.Equal(t, -1, m.previousDayBoundary(2))
360+
361+ // Previous day from E1-0(4) → first entry in group 0 = row 1
362+ assert.Equal(t, 1, m.previousDayBoundary(4))
363+
364+ // Previous day from first group's entry (E0-0 at 1) → no previous day
365+ assert.Equal(t, -1, m.previousDayBoundary(1))
366+
367+ // Next day from E0-0(1) → first entry in group 1 = row 4
368+ assert.Equal(t, 4, m.nextDayBoundary(1))
369+
370+ // Next day from E0-1(2) → row 4
371+ assert.Equal(t, 4, m.nextDayBoundary(2))
372+
373+ // Next day from E1-0(4) → first entry in group 2 = row 6
374+ assert.Equal(t, 6, m.nextDayBoundary(4))
375+
376+ // Next day from E2-0(6, group 2) → last group, no next day
377+ assert.Equal(t, -1, m.nextDayBoundary(6))
378+
379+ // Next day from E2-1(7, last entry) → no next day
380+ assert.Equal(t, -1, m.nextDayBoundary(7))
381+}
382+
383+func TestKeyboardNavModel_EntryAtURL(t *testing.T) {
384+ groups := makeGroups(1, 1)
385+ m := newKeyboardNavModel(groups)
386+
387+ e0 := m.entryAt(1)
388+ require.NotNil(t, e0)
389+ assert.Equal(t, "https://entry-0-0.com", e0.URL)
390+ assert.Equal(t, "https://entry-0-0.com", m.entryURLAt(1))
391+
392+ e1 := m.entryAt(3)
393+ require.NotNil(t, e1)
394+ assert.Equal(t, "https://entry-1-0.com", e1.URL)
395+
396+ // Header rows return nil / ""
397+ assert.Nil(t, m.entryAt(0)) // H0
398+ assert.Empty(t, m.entryURLAt(0))
399+ assert.Nil(t, m.entryAt(2)) // H1
400+ assert.Empty(t, m.entryURLAt(2))
401+
402+ // Out of range
403+ assert.Nil(t, m.entryAt(99))
404+ assert.Empty(t, m.entryURLAt(99))
405+ assert.Nil(t, m.entryAt(-1))
406+}
407+
408+func TestKeyboardNavModel_EntryCount(t *testing.T) {
409+ assert.Equal(t, 0, newKeyboardNavModel(nil).entryCount())
410+ assert.Equal(t, 0, newKeyboardNavModel([]historyGroup{}).entryCount())
411+ assert.Equal(t, 5, newKeyboardNavModel(makeGroups(2, 3)).entryCount())
412+ assert.Equal(t, 10, newKeyboardNavModel(makeGroups(3, 3, 4)).entryCount())
413+}
414+
415+func TestBuildHistoryDisplayRows_IncludesHeadersAndEntries(t *testing.T) {
416+ now := time.Now()
417+ entryA := &entity.HistoryEntry{ID: 1, URL: "https://a.com", Title: "A", LastVisited: now}
418+ entryB := &entity.HistoryEntry{ID: 2, URL: "https://b.com", Title: "B", LastVisited: now.Add(-time.Hour)}
419+ groups := []historyGroup{
420+ {Label: "Today", Entries: []*entity.HistoryEntry{entryA, entryB}},
421+ {Label: "Yesterday", Entries: nil},
422+ }
423+
424+ rows := buildHistoryDisplayRows(groups)
425+ require.Len(t, rows, 4)
426+ assert.Equal(t, historyDisplayRowHeader, rows[0].Kind)
427+ assert.Equal(t, "Today", rows[0].Label)
428+ assert.Equal(t, 0, rows[0].GroupIndex)
429+ assert.Equal(t, historyDisplayRowEntry, rows[1].Kind)
430+ assert.Same(t, entryA, rows[1].Entry)
431+ assert.Equal(t, 0, rows[1].GroupIndex)
432+ assert.Equal(t, historyDisplayRowEntry, rows[2].Kind)
433+ assert.Same(t, entryB, rows[2].Entry)
434+ assert.Equal(t, historyDisplayRowHeader, rows[3].Kind)
435+ assert.Equal(t, "Yesterday", rows[3].Label)
436+ assert.Equal(t, 1, rows[3].GroupIndex)
437+}
438+
439+func TestKeyboardNavModelFromRows_UsesExplicitDisplayRows(t *testing.T) {
440+ now := time.Now()
441+ entryA := &entity.HistoryEntry{ID: 1, URL: "https://a.com", Title: "A", LastVisited: now}
442+ entryB := &entity.HistoryEntry{ID: 2, URL: "https://b.com", Title: "B", LastVisited: now.Add(-24 * time.Hour)}
443+ rows := buildHistoryDisplayRows([]historyGroup{
444+ {Label: "Today", Entries: []*entity.HistoryEntry{entryA}},
445+ {Label: "Yesterday", Entries: []*entity.HistoryEntry{entryB}},
446+ })
447+
448+ m := newKeyboardNavModelFromRows(rows)
449+ assert.Equal(t, 4, m.totalRows())
450+ assert.False(t, m.isSelectable(0))
451+ assert.True(t, m.isSelectable(1))
452+ assert.False(t, m.isSelectable(2))
453+ assert.True(t, m.isSelectable(3))
454+ assert.Same(t, entryA, m.entryAt(1))
455+ assert.Equal(t, entryB.URL, m.entryURLAt(3))
456+ assert.Equal(t, 3, m.nextDayBoundary(1))
457+ assert.Equal(t, 1, m.previousDayBoundary(3))
458+}
459+
460+// =============================================================================
461+// Search state transition tests
462+// =============================================================================
463+
464+func TestTransitionSearchState_EmptyQuery(t *testing.T) {
465+ next := transitionSearchState("", 0)
466+ assert.Empty(t, next.Query)
467+ assert.False(t, next.HasSearchDone)
468+ assert.False(t, next.HasResults)
469+ assert.Equal(t, 0, next.ResultCount)
470+}
471+
472+func TestTransitionSearchState_NewQuery(t *testing.T) {
473+ next := transitionSearchState("example", 5)
474+ assert.Equal(t, "example", next.Query)
475+ assert.True(t, next.HasSearchDone)
476+ assert.True(t, next.HasResults)
477+ assert.Equal(t, 5, next.ResultCount)
478+}
479+
480+func TestTransitionSearchState_QueryWithNoResults(t *testing.T) {
481+ next := transitionSearchState("nonexistent", 0)
482+ assert.Equal(t, "nonexistent", next.Query)
483+ assert.True(t, next.HasSearchDone)
484+ assert.False(t, next.HasResults)
485+ assert.Equal(t, 0, next.ResultCount)
486+}
487+
488+func TestTransitionSearchState_QueryToQuery(t *testing.T) {
489+ next := transitionSearchState("new", 7)
490+ assert.Equal(t, "new", next.Query)
491+ assert.True(t, next.HasSearchDone)
492+ assert.True(t, next.HasResults)
493+ assert.Equal(t, 7, next.ResultCount)
494+}
495+
496+func TestTransitionSearchState_QueryToEmpty(t *testing.T) {
497+ next := transitionSearchState("", 0)
498+ assert.Empty(t, next.Query)
499+ assert.False(t, next.HasSearchDone)
500+ assert.False(t, next.HasResults)
501+ assert.Equal(t, 0, next.ResultCount)
502+}
503+
504+// =============================================================================
505+// Reload preservation tests
506+// =============================================================================
507+
508+func TestApplyReloadState_WithoutQuery(t *testing.T) {
509+ s := applyReloadState("")
510+ assert.Empty(t, s.PreservedQuery)
511+ assert.True(t, s.ResetBrowse)
512+ assert.False(t, s.ClearSearch)
513+}
514+
515+func TestApplyReloadState_WithQuery(t *testing.T) {
516+ s := applyReloadState("search-term")
517+ assert.Equal(t, "search-term", s.PreservedQuery)
518+ assert.False(t, s.ResetBrowse)
519+ assert.True(t, s.ClearSearch)
520+}
521+
522+// =============================================================================
523+// keyboardNavModel edge cases
524+// =============================================================================
525+
526+func TestKeyboardNavModel_SingleGroupZeroEntries(t *testing.T) {
527+ // A group with a header but zero entries.
528+ groups := []historyGroup{
529+ {Label: "Today", Entries: []*entity.HistoryEntry{}},
530+ }
531+ m := newKeyboardNavModel(groups)
532+
533+ assert.Equal(t, 1, m.totalRows()) // header only
534+ assert.False(t, m.isSelectable(0))
535+ assert.Equal(t, -1, m.firstSelectableIndex())
536+ assert.Equal(t, -1, m.lastSelectableIndex())
537+ assert.Nil(t, m.entryAt(0))
538+ assert.Equal(t, 0, m.entryCount())
539+}
540+
541+func TestKeyboardNavModel_MixedEmptyAndNonEmptyGroups(t *testing.T) {
542+ // Groups: [A(0 entries), B(2 entries), C(0 entries)]
543+ // Layout: H0(0), H1(1), E1-0(2), E1-1(3), H2(4)
544+ groups := []historyGroup{
545+ {Label: "EmptyA", Entries: []*entity.HistoryEntry{}},
546+ {Label: "HasTwo", Entries: []*entity.HistoryEntry{
547+ {ID: 1, URL: "https://b1.com", Title: "B1", LastVisited: time.Now()},
548+ {ID: 2, URL: "https://b2.com", Title: "B2", LastVisited: time.Now()},
549+ }},
550+ {Label: "EmptyC", Entries: []*entity.HistoryEntry{}},
551+ }
552+ m := newKeyboardNavModel(groups)
553+
554+ assert.Equal(t, 5, m.totalRows())
555+ // All headers: 0, 1, 4 are not selectable
556+ assert.False(t, m.isSelectable(0))
557+ assert.False(t, m.isSelectable(1))
558+ assert.False(t, m.isSelectable(4))
559+ // Entries: 2, 3 are selectable
560+ assert.True(t, m.isSelectable(2))
561+ assert.True(t, m.isSelectable(3))
562+
563+ assert.Equal(t, 2, m.firstSelectableIndex())
564+ assert.Equal(t, 3, m.lastSelectableIndex())
565+ assert.Equal(t, 2, m.entryCount())
566+
567+ // next/previous day boundary from group B (index 2) should cross
568+ // empty group A header to group C header
569+ // previousDayBoundary from entry in B (row 2) → group before B is A (no entries) → -1
570+ assert.Equal(t, -1, m.previousDayBoundary(2))
571+ // nextDayBoundary from entry in B (row 2) → group after B is C (no entries) → -1
572+ assert.Equal(t, -1, m.nextDayBoundary(2))
573+}
574+
575+func TestKeyboardNavModel_GroupIndexAt(t *testing.T) {
576+ groups := makeGroups(2, 0, 3) // A:2 entries, B:0 entries, C:3 entries
577+ // Layout: H0(0), E0-0(1), E0-1(2), H1(3), H2(4), E2-0(5), E2-1(6), E2-2(7)
578+ // Groups: A at rows 0-2 (header+2 entries), B at row 3 (header only), C at rows 4-7 (header+3 entries)
579+ m := newKeyboardNavModel(groups)
580+
581+ assert.Equal(t, 0, m.groupIndexAt(0), "row 0 should be in group 0 (A header)")
582+ assert.Equal(t, 0, m.groupIndexAt(1), "row 1 should be in group 0 (A entry)")
583+ assert.Equal(t, 1, m.groupIndexAt(3), "row 3 should be in group 1 (B header)")
584+ assert.Equal(t, 2, m.groupIndexAt(4), "row 4 should be in group 2 (C header)")
585+ assert.Equal(t, 2, m.groupIndexAt(7), "row 7 should be in group 2 (last C entry)")
586+ assert.Equal(t, -1, m.groupIndexAt(99), "out of range should return -1")
587+}
588+
589+func TestKeyboardNavModel_CumulativeOffsetAtGroup(t *testing.T) {
590+ groups := makeGroups(2, 0, 3)
591+ m := newKeyboardNavModel(groups)
592+
593+ assert.Equal(t, 0, m.cumulativeOffsetAtGroup(0), "group 0 offset should be 0")
594+ // Group 0: 1 header + 2 entries = 3 rows
595+ assert.Equal(t, 3, m.cumulativeOffsetAtGroup(1), "group 1 offset should skip group 0")
596+ // Group 1: 1 header + 0 entries = 1 row, so group 2 offset = 3 + 1 = 4
597+ assert.Equal(t, 4, m.cumulativeOffsetAtGroup(2), "group 2 offset should skip groups 0 and 1")
598+ assert.Equal(t, -1, m.cumulativeOffsetAtGroup(99), "out of range should return -1")
599+}
600+
601+func TestKeyboardNavModel_FirstEntryOfGroup(t *testing.T) {
602+ groups := makeGroups(2, 0, 3)
603+ m := newKeyboardNavModel(groups)
604+
605+ // Group 0: offset=0, first entry = 1
606+ assert.Equal(t, 1, m.firstEntryOfGroup(0))
607+ // Group 1: offset=3, first entry = 4 (but group has 0 entries -> -1)
608+ assert.Equal(t, -1, m.firstEntryOfGroup(1))
609+ // Group 2: offset=4, first entry = 5
610+ assert.Equal(t, 5, m.firstEntryOfGroup(2))
611+}
612+
613+func TestKeyboardNavModel_NextPreviousSelectable_EmptyGroups(t *testing.T) {
614+ // All groups have zero entries.
615+ groups := []historyGroup{
616+ {Label: "A", Entries: []*entity.HistoryEntry{}},
617+ {Label: "B", Entries: []*entity.HistoryEntry{}},
618+ }
619+ m := newKeyboardNavModel(groups)
620+
621+ assert.Equal(t, -1, m.nextSelectableIndex(0, 1))
622+ assert.Equal(t, -1, m.nextSelectableIndex(0, -1))
623+}
624+
625+func TestKeyboardNavModel_EntryCountWithEmptyGroups(t *testing.T) {
626+ groups := []historyGroup{
627+ {Label: "A", Entries: []*entity.HistoryEntry{{
628+ ID: 1, URL: "https://a.com", Title: "A", LastVisited: time.Now(),
629+ }}},
630+ {Label: "B", Entries: []*entity.HistoryEntry{}},
631+ {Label: "C", Entries: []*entity.HistoryEntry{{
632+ ID: 2, URL: "https://c.com", Title: "C", LastVisited: time.Now(),
633+ }}},
634+ }
635+ m := newKeyboardNavModel(groups)
636+ assert.Equal(t, 2, m.entryCount(), "only non-empty groups' entries should count")
637+}
638+
639+// =============================================================================
640+// Search generation / stale-result suppression (pure transition model)
641+// =============================================================================
642+
643+// TestTransitionSearchState_StaleGenBehavior documents that the search
644+// generation counter (searchGen) is a production-side concern for stale
645+// result suppression. The transitionSearchState pure function only models
646+// query/result transitions; the actual generation guard is enforced by
647+// the GTK idle callback comparing gen vs hs.searchGen.
648+//
649+// We test the callback pattern here by simulating two sequential searches
650+// where a later result arrives after the gen has moved on.
651+func TestTransitionSearchState_SequentialSearches(t *testing.T) {
652+ // Search 1: "foo" -> 5 results
653+ next1 := transitionSearchState("foo", 5)
654+ assert.Equal(t, "foo", next1.Query)
655+ assert.True(t, next1.HasSearchDone)
656+ assert.True(t, next1.HasResults)
657+ assert.Equal(t, 5, next1.ResultCount)
658+
659+ // Search 2: "foobar" -> 3 results (supersedes search 1)
660+ next2 := transitionSearchState("foobar", 3)
661+ assert.Equal(t, "foobar", next2.Query)
662+ assert.True(t, next2.HasSearchDone)
663+ assert.True(t, next2.HasResults)
664+ assert.Equal(t, 3, next2.ResultCount)
665+}
666+
667+// TestTransitionSearchState_SearchThenClearThenReSearch verifies the
668+// transition from search -> empty -> new search produces correct state.
669+func TestTransitionSearchState_SearchThenClearThenReSearch(t *testing.T) {
670+ // Search "term" -> 2 results
671+ s := transitionSearchState("term", 2)
672+ assert.Equal(t, "term", s.Query)
673+ assert.True(t, s.HasSearchDone)
674+ assert.True(t, s.HasResults)
675+ assert.Equal(t, 2, s.ResultCount)
676+
677+ // Clear: query becomes ""
678+ s = transitionSearchState("", 0)
679+ assert.Empty(t, s.Query)
680+ assert.False(t, s.HasSearchDone)
681+ assert.False(t, s.HasResults)
682+ assert.Equal(t, 0, s.ResultCount)
683+
684+ // Re-search: new term
685+ s = transitionSearchState("other", 7)
686+ assert.Equal(t, "other", s.Query)
687+ assert.True(t, s.HasSearchDone)
688+ assert.True(t, s.HasResults)
689+ assert.Equal(t, 7, s.ResultCount)
690+}
691+
692+// TestApplyReloadState_EmptyAndNonEmpty verifies all reload preservation
693+// states are correctly modeled by the pure function.
694+func TestApplyReloadState_EmptyAndNonEmpty(t *testing.T) {
695+ tt := []struct {
696+ name string
697+ query string
698+ wantQuery string
699+ wantReset bool
700+ wantClear bool
701+ }{
702+ {name: "empty query", query: "", wantQuery: "", wantReset: true, wantClear: false},
703+ {name: "non-empty", query: "search", wantQuery: "search", wantReset: false, wantClear: true},
704+ {name: "whitespace only", query: " ", wantQuery: " ", wantReset: false, wantClear: true},
705+ {name: "long query", query: "very long search query with many terms", wantQuery: "very long search query with many terms", wantReset: false, wantClear: true},
706+ }
707+
708+ for _, tc := range tt {
709+ t.Run(tc.name, func(t *testing.T) {
710+ s := applyReloadState(tc.query)
711+ assert.Equal(t, tc.wantQuery, s.PreservedQuery)
712+ assert.Equal(t, tc.wantReset, s.ResetBrowse)
713+ assert.Equal(t, tc.wantClear, s.ClearSearch)
714+ })
715+ }
716+}
717+
718+// TestDayLabelForKey_DifferentYears verifies multi-year scenarios produce
719+// the expected label format.
720+func TestDayLabelForKey_DifferentYears(t *testing.T) {
721+ now := time.Now()
722+ todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
723+
724+ // Same year, not today/yesterday
725+ future := now.AddDate(0, 0, -3)
726+ key := dayKey{future.Year(), future.Month(), future.Day()}
727+ label := dayLabelForKey(key, todayStart, now)
728+ assert.NotContains(t, label, "2006", "within-current-year label should not contain year")
729+
730+ // Previous year (now.Year()-1)
731+ lastYear := now.AddDate(-1, 0, 0)
732+ key = dayKey{lastYear.Year(), lastYear.Month(), lastYear.Day()}
733+ label = dayLabelForKey(key, todayStart, now)
734+ assert.Equal(t, lastYear.Format(dayLabelOtherYearFormat), label)
735+
736+ // Multiple years ago
737+ twoYearsAgo := now.AddDate(-2, 0, 0)
738+ key = dayKey{twoYearsAgo.Year(), twoYearsAgo.Month(), twoYearsAgo.Day()}
739+ label = dayLabelForKey(key, todayStart, now)
740+ assert.Contains(t, label, twoYearsAgo.Format("2006"), "multi-year-old label should include year")
741+}
A internal/ui/component/history_sidebar.go +357 -0
@@ -0,0 +1,357 @@
1+package component
2+
3+import (
4+ "context"
5+ "sync"
6+
7+ "github.com/bnema/puregotk/v4/glib"
8+ "github.com/bnema/puregotk/v4/gtk"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/domain/entity"
12+ "github.com/bnema/dumber/internal/logging"
13+ "github.com/bnema/dumber/internal/ui/layout"
14+ "github.com/rs/zerolog"
15+)
16+
17+// sidebar constants
18+const (
19+ sidebarMinWidth = 280
20+ sidebarDefaultWidth = 320
21+ sidebarSearchDebounceMs = 150
22+ sidebarPageSize = 100 // entries fetched per page
23+ sidebarSearchLimit = 100 // max FTS search results
24+)
25+
26+// HistorySidebarKeyboardAction enumerates the possible activation actions
27+// triggered by keyboard Enter variants.
28+type HistorySidebarKeyboardAction int
29+
30+const (
31+ // SidebarActionCloseOnActivate is the default activation path.
32+ // The host currently keeps the sidebar visible for default activation.
33+ SidebarActionCloseOnActivate HistorySidebarKeyboardAction = iota
34+ // SidebarActionKeepOpenOnActivate navigates but leaves the sidebar visible.
35+ SidebarActionKeepOpenOnActivate
36+ // SidebarActionNewPaneOnActivate navigates by opening the URL in a new pane/split.
37+ SidebarActionNewPaneOnActivate
38+)
39+
40+// HistorySidebar is a GTK sidebar component that displays browsing history
41+// grouped by day, with search/filter support and keyboard navigation.
42+// History is loaded page by page with background goroutines to avoid
43+// blocking the GTK main thread.
44+type HistorySidebar struct {
45+ // GTK widgets
46+ outerBox *gtk.Box
47+ searchBox *gtk.Box
48+ searchEntry *gtk.SearchEntry
49+ scrolledWin *gtk.ScrolledWindow
50+ listBox *gtk.ListBox
51+
52+ // Dependencies
53+ historyUC port.HistorySidebarHistory
54+ onURL func(ctx context.Context, url string)
55+ onOpenInNewPane func(ctx context.Context, url string) error
56+ onNavigateKeepOpen func(ctx context.Context, url string)
57+ onClose func()
58+
59+ // Data
60+ allEntries []*entity.HistoryEntry // Flat list, most-recent-first
61+ groups []historyGroup // Current display groups (filtered)
62+ displayRows []historyDisplayRow // Explicit rows currently rendered
63+
64+ // Paging state (browse mode only)
65+ totalLoaded int // how many entries have been fetched so far
66+ hasMore bool
67+ isLoading bool
68+ loadGen uint64 // incremented each new browse load; used for stale-result protection
69+
70+ // State
71+ visible bool
72+ currentQuery string
73+ loadStarted bool
74+ loadDone bool
75+ destroyed bool
76+ mu sync.RWMutex
77+ logger zerolog.Logger
78+
79+ // Search state
80+ searchResults []*entity.HistoryEntry // non-nil when a search has completed
81+ searchGen uint64 // incremented each search; used for stale-result protection
82+ searchDone bool // true when the last search completed
83+ searchErr error // last search error (if any)
84+
85+ // Scroll/selection preservation
86+ prevScrollValue float64
87+ prevSelectedURL string
88+
89+ // Search debounce timer
90+ debounceTimer uint
91+
92+ // Retained callbacks
93+ retainedCallbacks []interface{}
94+
95+ // idleScheduler dispatches work onto the GTK main thread.
96+ // Tests may override it to exercise scheduled callbacks deterministically.
97+ idleScheduler func(glib.SourceFunc)
98+
99+ // Context
100+ ctx context.Context
101+ cancel context.CancelFunc
102+}
103+
104+// HistorySidebarConfig holds configuration for creating a HistorySidebar.
105+type HistorySidebarConfig struct {
106+ // HistoryUC provides history query and delete operations.
107+ HistoryUC port.HistorySidebarHistory
108+
109+ // OnNavigate is called when the user activates a history entry.
110+ // The default Enter / click behavior keeps the sidebar open after navigating.
111+ OnNavigate func(ctx context.Context, url string) error
112+
113+ // OnOpenInNewPane is called when Shift+Enter activates a URL.
114+ // Should open the URL in a new pane/split. If nil, Shift+Enter
115+ // falls back to the default plain-Enter behavior.
116+ OnOpenInNewPane func(ctx context.Context, url string) error
117+
118+ // OnNavigateKeepOpen is called when Ctrl+Enter activates a URL.
119+ // Unlike OnNavigate, the sidebar must NOT be closed after this
120+ // callback returns. If nil, Ctrl+Enter falls back to OnNavigate.
121+ OnNavigateKeepOpen func(ctx context.Context, url string) error
122+
123+ // OnClose is called when the sidebar should close itself (e.g. Escape
124+ // with empty search). The host should hide the sidebar and restore
125+ // focus to the active content pane/webview.
126+ OnClose func()
127+}
128+
129+// NewHistorySidebar creates a new HistorySidebar component.
130+func NewHistorySidebar(ctx context.Context, cfg HistorySidebarConfig) *HistorySidebar {
131+ ctx, cancel := context.WithCancel(ctx)
132+ log := logging.FromContext(ctx).With().Str("component", "history-sidebar").Logger()
133+
134+ hs := &HistorySidebar{
135+ historyUC: cfg.HistoryUC,
136+ onURL: func(callCtx context.Context, url string) {
137+ if cfg.OnNavigate != nil {
138+ if err := cfg.OnNavigate(callCtx, url); err != nil {
139+ log.Error().Err(err).Str("url", url).Msg("history sidebar navigate failed")
140+ }
141+ }
142+ },
143+ onOpenInNewPane: func(callCtx context.Context, url string) error {
144+ if cfg.OnOpenInNewPane == nil {
145+ // Fall back to regular navigation when no new-pane handler set
146+ if cfg.OnNavigate != nil {
147+ return cfg.OnNavigate(callCtx, url)
148+ }
149+ return nil
150+ }
151+ return cfg.OnOpenInNewPane(callCtx, url)
152+ },
153+ onNavigateKeepOpen: func(callCtx context.Context, url string) {
154+ if cfg.OnNavigateKeepOpen != nil {
155+ if err := cfg.OnNavigateKeepOpen(callCtx, url); err != nil {
156+ log.Error().Err(err).Str("url", url).Msg("history sidebar keep-open navigate failed")
157+ }
158+ } else if cfg.OnNavigate != nil {
159+ // Fall back to the default navigate path when no dedicated
160+ // keep-open callback is configured.
161+ if err := cfg.OnNavigate(callCtx, url); err != nil {
162+ log.Error().Err(err).Str("url", url).Msg("history sidebar keep-open fallback failed")
163+ }
164+ }
165+ },
166+ onClose: func() {
167+ if cfg.OnClose != nil {
168+ cfg.OnClose()
169+ }
170+ },
171+ logger: log,
172+ ctx: ctx,
173+ cancel: cancel,
174+ hasMore: cfg.HistoryUC != nil,
175+ }
176+
177+ if err := hs.createWidgets(); err != nil {
178+ log.Error().Err(err).Msg("failed to create history sidebar widgets")
179+ cancel()
180+ return nil
181+ }
182+
183+ hs.setupSearchHandler()
184+ hs.setupScrollLoadMore()
185+ hs.setupKeyboardNavigation()
186+
187+ log.Debug().Msg("history sidebar created")
188+
189+ // Start loading history asynchronously (background goroutine)
190+ hs.startLoadHistory()
191+
192+ return hs
193+}
194+
195+// Destroy cleans up the sidebar and releases resources.
196+func (hs *HistorySidebar) Destroy() {
197+ hs.mu.Lock()
198+ if hs.destroyed {
199+ hs.mu.Unlock()
200+ return
201+ }
202+ hs.destroyed = true
203+ timerID := hs.debounceTimer
204+ hs.debounceTimer = 0
205+ hs.mu.Unlock()
206+
207+ if hs.cancel != nil {
208+ hs.cancel()
209+ }
210+
211+ if timerID != 0 {
212+ glib.SourceRemove(timerID)
213+ }
214+}
215+
216+// WidgetAsLayout returns the sidebar's outer widget for embedding.
217+func (hs *HistorySidebar) WidgetAsLayout(factory layout.WidgetFactory) layout.Widget {
218+ if hs.outerBox == nil {
219+ return nil
220+ }
221+ return factory.WrapWidget(&hs.outerBox.Widget)
222+}
223+
224+// Widget returns the raw GTK widget.
225+func (hs *HistorySidebar) Widget() *gtk.Widget {
226+ if hs.outerBox == nil {
227+ return nil
228+ }
229+ return &hs.outerBox.Widget
230+}
231+
232+// Show displays the sidebar and focuses the search entry.
233+// History data is refreshed asynchronously so Ctrl+H always shows
234+// current recent visits, not stale data from initialization.
235+func (hs *HistorySidebar) Show() {
236+ hs.mu.Lock()
237+ if hs.outerBox == nil || hs.destroyed {
238+ hs.mu.Unlock()
239+ return
240+ }
241+
242+ hs.outerBox.SetVisible(true)
243+ hs.visible = true
244+ hs.mu.Unlock()
245+
246+ // Schedule a background reload so the sidebar shows fresh data
247+ // when it becomes visible, not stale data captured at init time.
248+ reloadCb := glib.SourceFunc(func(uintptr) bool {
249+ hs.mu.RLock()
250+ destroyed := hs.destroyed
251+ hs.mu.RUnlock()
252+ if destroyed {
253+ return false
254+ }
255+ hs.Reload()
256+ return false
257+ })
258+ hs.scheduleIdle(reloadCb)
259+
260+ // Focus search entry via idle callback to ensure layout is stable.
261+ cb := glib.SourceFunc(func(uintptr) bool {
262+ hs.mu.RLock()
263+ destroyed := hs.destroyed
264+ entry := hs.searchEntry
265+ hs.mu.RUnlock()
266+ if destroyed || entry == nil {
267+ return false
268+ }
269+ entry.GrabFocus()
270+ return false
271+ })
272+ hs.scheduleIdle(cb)
273+}
274+
275+// Hide hides the sidebar.
276+func (hs *HistorySidebar) Hide() {
277+ hs.mu.Lock()
278+ defer hs.mu.Unlock()
279+
280+ if hs.outerBox == nil || hs.destroyed {
281+ return
282+ }
283+
284+ hs.outerBox.SetVisible(false)
285+ hs.visible = false
286+}
287+
288+// IsVisible returns whether the sidebar is visible.
289+func (hs *HistorySidebar) IsVisible() bool {
290+ hs.mu.RLock()
291+ defer hs.mu.RUnlock()
292+ return hs.visible
293+}
294+
295+// Reload triggers a fresh load of history data. Preserves the current search
296+// query, scroll position, and selection.
297+func (hs *HistorySidebar) Reload() {
298+ hs.mu.Lock()
299+ if hs.destroyed {
300+ hs.mu.Unlock()
301+ return
302+ }
303+ hs.preserveScrollAndSelection()
304+ savedQuery := hs.currentQuery
305+
306+ // Reset all data state but keep the query preserved so the search entry
307+ // text and internal state remain consistent.
308+ hs.loadDone = false
309+ hs.loadStarted = false
310+ hs.totalLoaded = 0
311+ hs.hasMore = hs.historyUC != nil
312+ hs.isLoading = false
313+ hs.allEntries = nil
314+ hs.setDisplayGroupsLocked(nil)
315+ hs.searchResults = nil
316+ hs.searchDone = false
317+ hs.searchErr = nil
318+
319+ if savedQuery == "" {
320+ hs.currentQuery = ""
321+ hs.mu.Unlock()
322+ hs.startLoadHistory()
323+ } else {
324+ hs.currentQuery = savedQuery
325+ hs.searchGen++ // Invalidate any in-flight search
326+ gen := hs.searchGen
327+ hs.mu.Unlock()
328+ hs.scheduleClearList()
329+ hs.doFTSearch(savedQuery, gen)
330+ }
331+}
332+
333+// SetSearchQuery externally sets the search text and triggers filtering.
334+func (hs *HistorySidebar) SetSearchQuery(query string) {
335+ hs.mu.RLock()
336+ entry := hs.searchEntry
337+ hs.mu.RUnlock()
338+ if entry != nil {
339+ entry.SetText(query)
340+ }
341+}
342+
343+// ClearSearch clears the search entry text.
344+func (hs *HistorySidebar) ClearSearch() {
345+ hs.SetSearchQuery("")
346+}
347+
348+// =============================================================================
349+// Helpers
350+// =============================================================================
351+
352+func safeSidebarString(s, fallback string) string {
353+ if s == "" {
354+ return fallback
355+ }
356+ return s
357+}
A internal/ui/component/history_sidebar_keyboard.go +598 -0
@@ -0,0 +1,598 @@
1+package component
2+
3+import (
4+ "github.com/bnema/puregotk/v4/gdk"
5+ "github.com/bnema/puregotk/v4/glib"
6+ "github.com/bnema/puregotk/v4/gtk"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+)
10+
11+// =============================================================================
12+// Keyboard navigation
13+// =============================================================================
14+
15+func (hs *HistorySidebar) setupKeyboardNavigation() {
16+ if hs.outerBox == nil {
17+ return
18+ }
19+
20+ // The ListBox already supports Up/Down arrow navigation natively.
21+ // We add a PhaseCapture key controller on the outerBox to intercept
22+ // keys before the ListBox processes them.
23+ keyController := gtk.NewEventControllerKey()
24+ if keyController == nil {
25+ return
26+ }
27+ keyController.SetPropagationPhase(gtk.PhaseCaptureValue)
28+
29+ keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool {
30+ switch keyval {
31+ // --- Escape: clear search or close sidebar ---
32+ case uint(gdk.KEY_Escape):
33+ if hs.searchEntry != nil && hs.searchEntry.GetText() != "" {
34+ hs.searchEntry.SetText("")
35+ return true
36+ }
37+ // Close sidebar explicitly and restore focus
38+ hs.closeSidebar()
39+ return true
40+
41+ // --- Enter variants ---
42+ case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
43+ return hs.handleEnterKey(state)
44+
45+ // --- Delete: remove selected entry ---
46+ case uint(gdk.KEY_Delete), uint(gdk.KEY_KP_Delete):
47+ if hs.searchEntry != nil && hs.searchEntry.GetText() != "" {
48+ return false
49+ }
50+ return hs.handleDeleteKey()
51+
52+ // --- PageUp / PageDown: scroll by page ---
53+ case uint(gdk.KEY_Page_Up):
54+ hs.scrollByPage(-1)
55+ return true
56+ case uint(gdk.KEY_Page_Down):
57+ hs.scrollByPage(1)
58+ return true
59+
60+ // --- Home / End: jump to first/last selectable row ---
61+ case uint(gdk.KEY_Home):
62+ hs.jumpToFirstSelectable()
63+ return true
64+ case uint(gdk.KEY_End):
65+ hs.jumpToLastSelectable()
66+ return true
67+
68+ // --- Up / Down: previous / next selectable row ---
69+ // Ctrl+Up / Ctrl+Down: previous / next day group jump ---
70+ case uint(gdk.KEY_Up):
71+ if state&gdk.ControlMaskValue != 0 {
72+ hs.jumpToPreviousDay()
73+ return true
74+ }
75+ hs.selectPreviousRow()
76+ return true
77+ case uint(gdk.KEY_Down):
78+ if state&gdk.ControlMaskValue != 0 {
79+ hs.jumpToNextDay()
80+ return true
81+ }
82+ hs.selectNextRow()
83+ return true
84+ }
85+
86+ return false
87+ }
88+
89+ hs.retainedCallbacks = append(hs.retainedCallbacks, keyPressedCb)
90+ keyController.ConnectKeyPressed(&keyPressedCb)
91+
92+ hs.outerBox.AddController(&keyController.EventController)
93+}
94+
95+// handleEnterKey processes Enter, Ctrl+Enter, and Shift+Enter on a selected row.
96+// Returns true if the key was consumed.
97+func (hs *HistorySidebar) handleEnterKey(state gdk.ModifierType) bool {
98+ // Determine activation mode from modifiers
99+ var action HistorySidebarKeyboardAction
100+
101+ switch {
102+ case state&gdk.ControlMaskValue != 0:
103+ // Ctrl+Enter: navigate but keep sidebar open
104+ action = SidebarActionKeepOpenOnActivate
105+ case state&gdk.ShiftMaskValue != 0:
106+ // Shift+Enter: navigate in new pane
107+ action = SidebarActionNewPaneOnActivate
108+ default:
109+ // Plain Enter: navigate using the default activation behavior.
110+ action = SidebarActionCloseOnActivate
111+ }
112+
113+ // Find the selected row and its URL
114+ row := hs.listBox.GetSelectedRow()
115+ if row == nil || !row.GetSelectable() {
116+ return false
117+ }
118+
119+ hs.mu.RLock()
120+ url := hs.entryURLAtIndex(row.GetIndex())
121+ hs.mu.RUnlock()
122+ if url == "" {
123+ return false
124+ }
125+
126+ // Schedule activation on the GTK main thread
127+ switch action {
128+ case SidebarActionKeepOpenOnActivate:
129+ hs.navigateWithoutClosing(url)
130+ case SidebarActionNewPaneOnActivate:
131+ hs.navigateToNewPane(url)
132+ default:
133+ hs.navigateToURL(url)
134+ }
135+
136+ // Consume the key event
137+ return true
138+}
139+
140+// handleDeleteKey removes the selected history entry and updates the selection.
141+// Returns true if the key was consumed.
142+func (hs *HistorySidebar) handleDeleteKey() bool {
143+ row := hs.listBox.GetSelectedRow()
144+ if row == nil || !row.GetSelectable() {
145+ return false
146+ }
147+ if hs.historyUC == nil {
148+ return false
149+ }
150+
151+ idx := row.GetIndex()
152+
153+ hs.mu.RLock()
154+ url := hs.entryURLAtIndex(idx)
155+ entryID := hs.findEntryIDByIndex(idx)
156+ nextSelectedURL := ""
157+ if nextRow := hs.findNextSelectableAfter(idx); nextRow != -1 {
158+ nextSelectedURL = hs.entryURLAtIndex(nextRow)
159+ }
160+ hs.mu.RUnlock()
161+
162+ if url == "" || entryID <= 0 {
163+ return false
164+ }
165+
166+ go func() {
167+ if err := hs.historyUC.Delete(hs.ctx, entryID); err != nil {
168+ hs.logger.Error().Err(err).Int64("entry_id", entryID).Msg("failed to delete history entry")
169+ return
170+ }
171+
172+ cb := glib.SourceFunc(func(uintptr) bool {
173+ hs.mu.Lock()
174+ if hs.destroyed {
175+ hs.mu.Unlock()
176+ return false
177+ }
178+ hs.applyDeletedEntryLocked(entryID, nextSelectedURL)
179+ hs.mu.Unlock()
180+
181+ hs.rebuildList()
182+ return false
183+ })
184+ hs.scheduleIdle(cb)
185+ }()
186+
187+ return true
188+}
189+
190+// applyDeletedEntryLocked updates local sidebar state after a successful
191+// history delete. Must be called with hs.mu write lock held.
192+func (hs *HistorySidebar) applyDeletedEntryLocked(entryID int64, nextSelectedURL string) {
193+ hs.preserveScrollAndSelection()
194+ hs.prevSelectedURL = nextSelectedURL
195+ hs.searchGen++
196+ hs.loadGen++
197+ hs.isLoading = false
198+ hs.loadStarted = false
199+ hs.removeFromAllEntries(entryID)
200+ hs.totalLoaded = len(hs.allEntries)
201+ hs.removeFromSearchResults(entryID)
202+ hs.rebuildLocalGroups()
203+}
204+
205+// findEntryIDByIndex returns the entry ID for the linear ListBox index.
206+// Must be called with hs.mu read lock held.
207+func (hs *HistorySidebar) findEntryIDByIndex(index int) int64 {
208+ entry := newKeyboardNavModelFromRows(hs.displayRows).entryAt(index)
209+ if entry == nil {
210+ return 0
211+ }
212+ return entry.ID
213+}
214+
215+// rebuildLocalGroups rebuilds hs.groups from the current allEntries and query.
216+// Must be called with hs.mu write lock held.
217+func (hs *HistorySidebar) rebuildLocalGroups() {
218+ if hs.currentQuery == "" {
219+ hs.setDisplayGroupsLocked(groupHistoryByDay(hs.allEntries))
220+ } else if hs.searchResults != nil {
221+ hs.setDisplayGroupsLocked(groupHistoryByDay(hs.searchResults))
222+ } else {
223+ // For search mode, the search results are handled by doFTSearch.
224+ // Removing an entry while in search mode would need a re-search.
225+ // Fall back to grouping searchResults if they exist.
226+ hs.setDisplayGroupsLocked(nil)
227+ }
228+}
229+
230+// removeFromAllEntries removes all history entries matching the given URL or ID
231+// from hs.allEntries. Must be called with hs.mu write lock held.
232+func (hs *HistorySidebar) removeFromAllEntries(id int64) {
233+ filtered := make([]*entity.HistoryEntry, 0, len(hs.allEntries))
234+ for _, e := range hs.allEntries {
235+ if e != nil && e.ID == id {
236+ continue
237+ }
238+ filtered = append(filtered, e)
239+ }
240+ hs.allEntries = filtered
241+}
242+
243+// removeFromSearchResults removes all history entries matching the given ID
244+// from hs.searchResults. Must be called with hs.mu write lock held.
245+func (hs *HistorySidebar) removeFromSearchResults(id int64) {
246+ if hs.searchResults == nil {
247+ return
248+ }
249+ filtered := make([]*entity.HistoryEntry, 0, len(hs.searchResults))
250+ for _, e := range hs.searchResults {
251+ if e != nil && e.ID == id {
252+ continue
253+ }
254+ filtered = append(filtered, e)
255+ }
256+ hs.searchResults = filtered
257+}
258+
259+// findNextSelectableAfter returns the ListBox index of the next selectable
260+// row after the given index, falling back to the previous selectable row.
261+// Must be called with hs.mu read lock held.
262+func (hs *HistorySidebar) findNextSelectableAfter(idx int) int {
263+ model := newKeyboardNavModelFromRows(hs.displayRows)
264+ if next := model.nextSelectableIndex(idx, +1); next != -1 {
265+ return next
266+ }
267+ if prev := model.nextSelectableIndex(idx, -1); prev != -1 {
268+ return prev
269+ }
270+ return -1
271+}
272+
273+// scrollByPage scrolls the list by one page up or down,
274+// keeping the selection visible.
275+func (hs *HistorySidebar) scrollByPage(direction int) {
276+ if hs.scrolledWin == nil {
277+ return
278+ }
279+ vadj := hs.scrolledWin.GetVadjustment()
280+ if vadj == nil {
281+ return
282+ }
283+ pageSize := vadj.GetPageSize()
284+ current := vadj.GetValue()
285+ newVal := current + float64(direction)*(pageSize*0.9) // 90% page for overlap
286+ if newVal < 0 {
287+ newVal = 0
288+ }
289+ upper := vadj.GetUpper() - pageSize
290+ if newVal > upper {
291+ newVal = upper
292+ }
293+ if newVal >= 0 {
294+ vadj.SetValue(newVal)
295+ }
296+}
297+
298+// jumpToPreviousDay selects the first entry in the previous day group
299+// relative to the currently selected row.
300+func (hs *HistorySidebar) jumpToPreviousDay() {
301+ currentIdx := -1
302+ if row := hs.listBox.GetSelectedRow(); row != nil {
303+ currentIdx = row.GetIndex()
304+ }
305+
306+ hs.mu.RLock()
307+ targetIdx := newKeyboardNavModelFromRows(hs.displayRows).previousDayBoundary(currentIdx)
308+ hs.mu.RUnlock()
309+ if targetIdx == -1 {
310+ hs.jumpToFirstSelectable()
311+ return
312+ }
313+ if row := hs.listBox.GetRowAtIndex(targetIdx); row != nil && row.GetSelectable() {
314+ hs.listBox.SelectRow(row)
315+ hs.scrollRowIntoView(targetIdx)
316+ return
317+ }
318+ hs.jumpToFirstSelectable()
319+}
320+
321+// jumpToNextDay selects the first entry in the next day group
322+// relative to the currently selected row.
323+func (hs *HistorySidebar) jumpToNextDay() {
324+ currentIdx := -1
325+ if row := hs.listBox.GetSelectedRow(); row != nil {
326+ currentIdx = row.GetIndex()
327+ }
328+
329+ hs.mu.RLock()
330+ targetIdx := newKeyboardNavModelFromRows(hs.displayRows).nextDayBoundary(currentIdx)
331+ hs.mu.RUnlock()
332+ if targetIdx == -1 {
333+ hs.jumpToLastSelectable()
334+ return
335+ }
336+ if row := hs.listBox.GetRowAtIndex(targetIdx); row != nil && row.GetSelectable() {
337+ hs.listBox.SelectRow(row)
338+ hs.scrollRowIntoView(targetIdx)
339+ return
340+ }
341+ hs.jumpToLastSelectable()
342+}
343+
344+// scrollRowIntoView scrolls the scrolled window to ensure the row at
345+// the given ListBox index is visible.
346+func (hs *HistorySidebar) scrollRowIntoView(index int) {
347+ hs.ensureRowVisible(index)
348+}
349+
350+// jumpToFirstSelectable selects the first selectable row in the list.
351+func (hs *HistorySidebar) jumpToFirstSelectable() {
352+ for i := 0; ; i++ {
353+ row := hs.listBox.GetRowAtIndex(i)
354+ if row == nil {
355+ break
356+ }
357+ if row.GetSelectable() {
358+ hs.listBox.SelectRow(row)
359+ hs.ensureRowVisible(i)
360+ return
361+ }
362+ }
363+}
364+
365+// jumpToLastSelectable selects the last selectable row in the list.
366+func (hs *HistorySidebar) jumpToLastSelectable() {
367+ // Walk backwards through the rows
368+ maxIdx := 0
369+ var lastRow *gtk.ListBoxRow
370+ for i := 0; ; i++ {
371+ row := hs.listBox.GetRowAtIndex(i)
372+ if row == nil {
373+ break
374+ }
375+ maxIdx = i
376+ if row.GetSelectable() {
377+ lastRow = row
378+ }
379+ }
380+ // GetRowAtIndex returns nil once the index is past the end of the list,
381+ // so this scan terminates naturally at the first missing row.
382+ // If we found a selectable row, try it. Otherwise fall back to last row.
383+ if lastRow != nil {
384+ hs.listBox.SelectRow(lastRow)
385+ hs.ensureRowVisible(lastRow.GetIndex())
386+ return
387+ }
388+ // Fallback: last row regardless of selectability
389+ if maxIdx > 0 {
390+ if row := hs.listBox.GetRowAtIndex(maxIdx); row != nil {
391+ hs.listBox.SelectRow(row)
392+ hs.ensureRowVisible(maxIdx)
393+ }
394+ }
395+}
396+
397+// =============================================================================
398+// Up/Down row selection (with search entry focus preserved)
399+// =============================================================================
400+
401+// selectPreviousRow selects the previous selectable row (skipping headers).
402+// Focus remains in the search entry; the ListBox selection is updated
403+// programmatically and scrolled into view.
404+func (hs *HistorySidebar) selectPreviousRow() {
405+ hs.selectAdjacentRow(-1)
406+}
407+
408+// selectNextRow selects the next selectable row (skipping headers).
409+// Focus remains in the search entry; the ListBox selection is updated
410+// programmatically and scrolled into view.
411+func (hs *HistorySidebar) selectNextRow() {
412+ hs.selectAdjacentRow(1)
413+}
414+
415+// selectAdjacentRow moves selection by direction (-1 or +1) to the next
416+// selectable row, skipping non-selectable (header) rows. If nothing is
417+// currently selected, it selects the first (down) or last (up) selectable
418+// row. Focus remains in the search entry.
419+func (hs *HistorySidebar) selectAdjacentRow(direction int) {
420+ if hs.listBox == nil {
421+ return
422+ }
423+
424+ current := -1
425+ if row := hs.listBox.GetSelectedRow(); row != nil {
426+ current = row.GetIndex()
427+ }
428+
429+ hs.mu.RLock()
430+ model := newKeyboardNavModelFromRows(hs.displayRows)
431+ var target int
432+ if current < 0 {
433+ if direction > 0 {
434+ target = model.firstSelectableIndex()
435+ } else {
436+ target = model.lastSelectableIndex()
437+ }
438+ } else {
439+ target = model.nextSelectableIndex(current, direction)
440+ }
441+ hs.mu.RUnlock()
442+
443+ if target == -1 {
444+ return
445+ }
446+ if row := hs.listBox.GetRowAtIndex(target); row != nil && row.GetSelectable() {
447+ hs.listBox.SelectRow(row)
448+ hs.ensureRowVisible(target)
449+ }
450+}
451+
452+// ensureRowVisible adjusts the scrolled window so the row at index is
453+// visible, WITHOUT calling GrabFocus (preserving search entry focus).
454+// The Y position is computed by summing the allocated heights of all
455+// preceding rows.
456+func (hs *HistorySidebar) ensureRowVisible(index int) {
457+ if hs.scrolledWin == nil || hs.listBox == nil {
458+ return
459+ }
460+ vadj := hs.scrolledWin.GetVadjustment()
461+ if vadj == nil {
462+ return
463+ }
464+ row := hs.listBox.GetRowAtIndex(index)
465+ if row == nil {
466+ return
467+ }
468+
469+ // Sum allocated heights of all preceding rows to estimate Y position.
470+ var yPos int
471+ for i := 0; i < index; i++ {
472+ r := hs.listBox.GetRowAtIndex(i)
473+ if r == nil {
474+ continue
475+ }
476+ yPos += r.GetAllocatedHeight()
477+ }
478+
479+ rowHeight := row.GetAllocatedHeight()
480+ if rowHeight <= 0 {
481+ return
482+ }
483+
484+ pageSize := vadj.GetPageSize()
485+ current := vadj.GetValue()
486+ rowTop := float64(yPos)
487+ rowBottom := rowTop + float64(rowHeight)
488+
489+ if rowTop < current {
490+ // Row is above the visible area — scroll up.
491+ vadj.SetValue(rowTop)
492+ } else if rowBottom > current+pageSize {
493+ // Row is below the visible area — scroll down.
494+ vadj.SetValue(rowBottom - pageSize)
495+ }
496+}
497+
498+// =============================================================================
499+// Row activation (Enter / click)
500+// =============================================================================
501+
502+func (hs *HistorySidebar) onRowActivated(row *gtk.ListBoxRow) {
503+ if row == nil || !row.GetSelectable() {
504+ return
505+ }
506+
507+ hs.mu.RLock()
508+ // Allow activation when browse is loaded or when search results are available.
509+ // This prevents a race where the user searches before the initial browse page
510+ // finishes loading — browse may be left unloaded, but search results should
511+ // still be activatable.
512+ hasSearchResults := hs.searchDone && hs.searchResults != nil
513+ if (!hs.loadDone && !hasSearchResults) || len(hs.groups) == 0 {
514+ hs.mu.RUnlock()
515+ return
516+ }
517+ entry := newKeyboardNavModelFromRows(hs.displayRows).entryAt(row.GetIndex())
518+ hs.mu.RUnlock()
519+ if entry == nil || entry.URL == "" {
520+ return
521+ }
522+
523+ hs.navigateToURL(entry.URL)
524+}
525+
526+func (hs *HistorySidebar) navigateToURL(url string) {
527+ if hs.onURL == nil || url == "" {
528+ return
529+ }
530+
531+ navigateCb := glib.SourceFunc(func(uintptr) bool {
532+ hs.mu.RLock()
533+ destroyed := hs.destroyed
534+ hs.mu.RUnlock()
535+ if destroyed {
536+ return false
537+ }
538+ hs.onURL(hs.ctx, url)
539+ return false
540+ })
541+ hs.scheduleIdle(navigateCb)
542+}
543+
544+// navigateWithoutClosing navigates to the URL but does NOT close the sidebar.
545+// Used by Ctrl+Enter activation.
546+func (hs *HistorySidebar) navigateWithoutClosing(url string) {
547+ if hs.onNavigateKeepOpen == nil || url == "" {
548+ return
549+ }
550+ hs.doNavigateWithoutClose(url)
551+}
552+
553+// doNavigateWithoutClose schedules navigation without closing the sidebar.
554+// Uses the dedicated OnNavigateKeepOpen path so hosts can override the
555+// default activation behavior when they need a distinct keep-open action.
556+func (hs *HistorySidebar) doNavigateWithoutClose(url string) {
557+ navigateCb := glib.SourceFunc(func(uintptr) bool {
558+ hs.mu.RLock()
559+ destroyed := hs.destroyed
560+ hs.mu.RUnlock()
561+ if destroyed {
562+ return false
563+ }
564+ hs.onNavigateKeepOpen(hs.ctx, url)
565+ return false
566+ })
567+ hs.scheduleIdle(navigateCb)
568+}
569+
570+// navigateToNewPane navigates to the URL by opening it in a new pane.
571+// The sidebar stays open. Used by Shift+Enter activation.
572+func (hs *HistorySidebar) navigateToNewPane(url string) {
573+ if hs.onOpenInNewPane == nil || url == "" {
574+ return
575+ }
576+
577+ navigateCb := glib.SourceFunc(func(uintptr) bool {
578+ hs.mu.RLock()
579+ destroyed := hs.destroyed
580+ hs.mu.RUnlock()
581+ if destroyed {
582+ return false
583+ }
584+ if err := hs.onOpenInNewPane(hs.ctx, url); err != nil {
585+ hs.logger.Error().Err(err).Str("url", url).Msg("history sidebar new-pane navigation failed")
586+ }
587+ return false
588+ })
589+ hs.scheduleIdle(navigateCb)
590+}
591+
592+// closeSidebar calls the configured OnClose callback to tell the host to
593+// hide the sidebar and restore focus to the active content pane/webview.
594+func (hs *HistorySidebar) closeSidebar() {
595+ if hs.onClose != nil {
596+ hs.onClose()
597+ }
598+}
A internal/ui/component/history_sidebar_loading_search.go +442 -0
@@ -0,0 +1,442 @@
1+package component
2+
3+import (
4+ "github.com/bnema/puregotk/v4/glib"
5+ "github.com/bnema/puregotk/v4/gtk"
6+
7+ "github.com/bnema/dumber/internal/application/dto"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+)
10+
11+// =============================================================================
12+// Data loading — background goroutine with paging
13+// =============================================================================
14+
15+func (hs *HistorySidebar) startLoadHistory() {
16+ hs.mu.Lock()
17+ hs.loadGen++
18+ gen := hs.loadGen
19+ hs.loadStarted = true
20+ hs.isLoading = true
21+ hs.mu.Unlock()
22+
23+ // Fetch first page in a background goroutine
24+ go hs.fetchPage(0, gen)
25+}
26+
27+// fetchPage fetches a page of history entries in a background goroutine
28+// and schedules the UI update on the GTK main thread.
29+func (hs *HistorySidebar) fetchPage(offset int, gen uint64) {
30+ hs.mu.RLock()
31+ uc := hs.historyUC
32+ ctx := hs.ctx
33+ hs.mu.RUnlock()
34+
35+ if uc == nil || ctx == nil {
36+ // No provider; show empty state
37+ cb := glib.SourceFunc(func(uintptr) bool {
38+ hs.mu.Lock()
39+ if hs.destroyed {
40+ hs.mu.Unlock()
41+ return false
42+ }
43+ hs.loadStarted = false
44+ hs.isLoading = false
45+ hs.loadDone = true
46+ hs.hasMore = false
47+ hs.mu.Unlock()
48+ hs.scheduleRebuild()
49+ return false
50+ })
51+ hs.scheduleIdle(cb)
52+ return
53+ }
54+
55+ entries, err := uc.GetRecent(ctx, sidebarPageSize, offset)
56+ if err != nil {
57+ hs.logger.Error().Err(err).Int("offset", offset).Msg("failed to load history page")
58+ }
59+
60+ if entries == nil {
61+ entries = []*entity.HistoryEntry{}
62+ }
63+
64+ hasMore := len(entries) >= sidebarPageSize
65+
66+ hs.mu.Lock()
67+
68+ // If a newer load was started since this fetch began, drop stale results.
69+ // Must NOT mutate isLoading/loadStarted — they belong to the current
70+ // generation set by startLoadHistory or LoadMore.
71+ if gen != hs.loadGen {
72+ hs.mu.Unlock()
73+ return
74+ }
75+
76+ // If search is active, don't update browse state with stale page data
77+ // and don't overwrite search results.
78+ if hs.currentQuery != "" {
79+ hs.isLoading = false
80+ hs.loadStarted = false
81+ hs.mu.Unlock()
82+ return
83+ }
84+
85+ hs.totalLoaded = offset + len(entries)
86+ hs.hasMore = hasMore
87+ hs.isLoading = false
88+ hs.loadStarted = false
89+ hs.loadDone = true
90+
91+ if offset == 0 {
92+ // First page: replace all entries
93+ hs.allEntries = entries
94+ } else {
95+ // Subsequent page: append
96+ hs.allEntries = append(hs.allEntries, entries...)
97+ }
98+
99+ // Group for display
100+ hs.setDisplayGroupsLocked(groupHistoryByDay(hs.allEntries))
101+ hs.mu.Unlock()
102+
103+ // Schedule UI rebuild on GTK main thread
104+ cb := glib.SourceFunc(func(uintptr) bool {
105+ hs.rebuildList()
106+ return false
107+ })
108+ hs.scheduleIdle(cb)
109+}
110+
111+// LoadMore fetches the next page and appends it to the existing entries.
112+func (hs *HistorySidebar) LoadMore() {
113+ hs.mu.Lock()
114+ if hs.isLoading || !hs.hasMore || hs.destroyed || hs.currentQuery != "" {
115+ hs.mu.Unlock()
116+ return
117+ }
118+ hs.isLoading = true
119+ offset := hs.totalLoaded
120+ gen := hs.loadGen
121+ hs.mu.Unlock()
122+
123+ hs.logger.Debug().Int("offset", offset).Msg("loading more history entries")
124+ go hs.fetchPage(offset, gen)
125+}
126+
127+// =============================================================================
128+// Scroll-aware load-more: detects when the user reaches the bottom
129+// =============================================================================
130+
131+func (hs *HistorySidebar) setupScrollLoadMore() {
132+ if hs.scrolledWin == nil {
133+ return
134+ }
135+
136+ vadj := hs.scrolledWin.GetVadjustment()
137+ if vadj == nil {
138+ return
139+ }
140+
141+ changedCb := func(_ gtk.Adjustment) {
142+ hs.mu.RLock()
143+ if hs.destroyed || !hs.hasMore || hs.isLoading {
144+ hs.mu.RUnlock()
145+ return
146+ }
147+ value := vadj.GetValue()
148+ upper := vadj.GetUpper()
149+ pageSize := vadj.GetPageSize()
150+ hs.mu.RUnlock()
151+
152+ // Trigger load-more when within 200px of the bottom
153+ if pageSize > 0 && value+pageSize >= upper-200.0 {
154+ hs.LoadMore()
155+ }
156+ }
157+ hs.retainedCallbacks = append(hs.retainedCallbacks, changedCb)
158+ vadj.ConnectValueChanged(&changedCb)
159+}
160+
161+// =============================================================================
162+// Scroll/selection preservation
163+// =============================================================================
164+
165+// preserveScrollAndSelection saves the current scroll position and selected row
166+// URL before a rebuild. Must be called with hs.mu write lock held.
167+func (hs *HistorySidebar) preserveScrollAndSelection() {
168+ hs.prevScrollValue = 0
169+ hs.prevSelectedURL = ""
170+
171+ if hs.scrolledWin != nil {
172+ if vadj := hs.scrolledWin.GetVadjustment(); vadj != nil {
173+ hs.prevScrollValue = vadj.GetValue()
174+ }
175+ }
176+ if hs.listBox != nil {
177+ if selected := hs.listBox.GetSelectedRow(); selected != nil {
178+ if url := hs.entryURLAtIndex(selected.GetIndex()); url != "" {
179+ hs.prevSelectedURL = url
180+ }
181+ }
182+ }
183+}
184+
185+// restoreScrollAndSelection restores the previously saved scroll position and
186+// selection after a rebuild. Called on the GTK main thread.
187+func (hs *HistorySidebar) restoreScrollAndSelection() {
188+ // Restore selection first (changes scroll position)
189+ if hs.prevSelectedURL != "" {
190+ hs.selectRowByURL(hs.prevSelectedURL)
191+ }
192+
193+ // Then restore scroll position if we have one
194+ if hs.prevScrollValue > 0 && hs.scrolledWin != nil {
195+ if vadj := hs.scrolledWin.GetVadjustment(); vadj != nil {
196+ maxVal := vadj.GetUpper() - vadj.GetPageSize()
197+ if hs.prevScrollValue > maxVal {
198+ hs.prevScrollValue = maxVal
199+ }
200+ vadj.SetValue(hs.prevScrollValue)
201+ }
202+ }
203+
204+ hs.prevScrollValue = 0
205+ hs.prevSelectedURL = ""
206+}
207+
208+// getRowURL extracts the URL stored in a list box row.
209+func (hs *HistorySidebar) getRowURL(row *gtk.ListBoxRow) string {
210+ if row == nil || !row.GetSelectable() {
211+ return ""
212+ }
213+ child := row.GetChild()
214+ if child == nil {
215+ return ""
216+ }
217+
218+ // The child is the vertical box. Walk children to find our stored URL.
219+ // We store the URL directly on the row as data.
220+ // Actually, let's use a simpler approach: walk the list box to find the entry.
221+ idx := row.GetIndex()
222+ hs.mu.RLock()
223+ defer hs.mu.RUnlock()
224+
225+ return hs.entryURLAtIndex(idx)
226+}
227+
228+// entryURLAtIndex returns the URL of the history entry at the given
229+// linear list index (including group headers which return "").
230+func (hs *HistorySidebar) entryURLAtIndex(index int) string {
231+ return newKeyboardNavModelFromRows(hs.displayRows).entryURLAt(index)
232+}
233+
234+// selectRowByURL finds and selects a row whose URL matches.
235+func (hs *HistorySidebar) selectRowByURL(url string) {
236+ if url == "" || hs.listBox == nil {
237+ return
238+ }
239+ for i := 0; ; i++ {
240+ row := hs.listBox.GetRowAtIndex(i)
241+ if row == nil {
242+ break
243+ }
244+ if !row.GetSelectable() {
245+ continue
246+ }
247+ if hs.getRowURL(row) == url {
248+ hs.listBox.SelectRow(row)
249+ return
250+ }
251+ }
252+}
253+
254+// =============================================================================
255+// Search / filtering
256+// =============================================================================
257+
258+func (hs *HistorySidebar) setupSearchHandler() {
259+ if hs.searchEntry == nil {
260+ return
261+ }
262+
263+ changedCb := func(_ gtk.SearchEntry) {
264+ hs.onSearchChanged()
265+ }
266+ hs.retainedCallbacks = append(hs.retainedCallbacks, changedCb)
267+ hs.searchEntry.ConnectSearchChanged(&changedCb)
268+}
269+
270+func (hs *HistorySidebar) onSearchChanged() {
271+ hs.mu.Lock()
272+ if hs.destroyed {
273+ hs.mu.Unlock()
274+ return
275+ }
276+ hs.currentQuery = hs.searchEntry.GetText()
277+ hs.preserveScrollAndSelection()
278+ oldTimer := hs.debounceTimer
279+ hs.debounceTimer = 0
280+ hs.mu.Unlock()
281+
282+ if oldTimer != 0 {
283+ glib.SourceRemove(oldTimer)
284+ }
285+
286+ filterCb := glib.SourceFunc(func(uintptr) bool {
287+ hs.applyFilter()
288+ return false
289+ })
290+ timerID := glib.TimeoutAdd(uint(sidebarSearchDebounceMs), &filterCb, 0)
291+
292+ hs.mu.Lock()
293+ if hs.destroyed {
294+ hs.mu.Unlock()
295+ if timerID != 0 {
296+ glib.SourceRemove(timerID)
297+ }
298+ return
299+ }
300+ hs.debounceTimer = timerID
301+ hs.mu.Unlock()
302+}
303+
304+func (hs *HistorySidebar) applyFilter() {
305+ hs.mu.Lock()
306+ hs.debounceTimer = 0
307+ query := hs.currentQuery
308+
309+ if query == "" {
310+ // Empty query: use in-memory browse entries (paged getRecent).
311+ // Clear search state and invalidate any in-flight search so a late
312+ // search result doesn't overwrite browse state.
313+ hs.searchResults = nil
314+ hs.searchDone = false
315+ hs.searchGen++
316+ hs.setDisplayGroupsLocked(nil)
317+ if !hs.loadDone {
318+ // Browse was never fully loaded (e.g., a search superseded the
319+ // initial page fetch). Clear the list, show a loading indicator,
320+ // and restart loading history in the background.
321+ hs.mu.Unlock()
322+ hs.scheduleRebuild() // Shows "Loading history…" while fetch runs
323+ hs.startLoadHistory()
324+ return
325+ }
326+ hs.setDisplayGroupsLocked(groupHistoryByDay(hs.allEntries))
327+ hs.mu.Unlock()
328+ hs.scheduleRebuild()
329+ return
330+ }
331+
332+ // Non-empty query: use real FTS search via the provider.
333+ // Cancel any stale in-flight search via generation counter.
334+ hs.searchGen++
335+ gen := hs.searchGen
336+ hs.searchDone = false
337+ hs.searchResults = nil
338+ hs.setDisplayGroupsLocked(nil)
339+ hs.mu.Unlock()
340+
341+ // Clear the list immediately to avoid showing stale browse results
342+ // while the search is in flight.
343+ hs.scheduleClearList()
344+
345+ hs.doFTSearch(query, gen)
346+}
347+
348+// doFTSearch runs a history FTS search in a background goroutine and
349+// updates the display when results arrive. Stale results (from a superseded
350+// search generation) are silently dropped.
351+func (hs *HistorySidebar) doFTSearch(query string, gen uint64) {
352+ hs.mu.RLock()
353+ uc := hs.historyUC
354+ ctx := hs.ctx
355+ hs.mu.RUnlock()
356+
357+ if uc == nil || ctx == nil {
358+ return
359+ }
360+
361+ go func() {
362+ out, err := uc.Search(ctx, dto.HistorySearchInput{Query: query, Limit: sidebarSearchLimit})
363+ var entries []*entity.HistoryEntry
364+ if out != nil {
365+ entries = make([]*entity.HistoryEntry, len(out.Matches))
366+ for i, m := range out.Matches {
367+ entries[i] = m.Entry
368+ }
369+ }
370+ if err != nil {
371+ hs.logger.Error().Err(err).Str("query", query).Msg("history FTS search failed")
372+ }
373+ if entries == nil {
374+ entries = []*entity.HistoryEntry{}
375+ }
376+
377+ // Apply results on the GTK main thread with stale-result protection
378+ cb := glib.SourceFunc(func(uintptr) bool {
379+ if hs.applySearchResults(entries, gen, err) {
380+ hs.scheduleRebuild()
381+ }
382+ return false
383+ })
384+ hs.scheduleIdle(cb)
385+ }()
386+}
387+
388+// applySearchResults applies search results under the generation guard.
389+// Returns true if results were applied (non-stale), false if the generation
390+// had moved on and the results were dropped.
391+func (hs *HistorySidebar) applySearchResults(entries []*entity.HistoryEntry, gen uint64, err error) bool {
392+ hs.mu.Lock()
393+ defer hs.mu.Unlock()
394+ if hs.destroyed || gen != hs.searchGen {
395+ return false
396+ }
397+ hs.searchResults = entries
398+ hs.searchDone = true
399+ hs.searchErr = err
400+ hs.setDisplayGroupsLocked(groupHistoryByDay(entries))
401+ return true
402+}
403+
404+func (hs *HistorySidebar) scheduleIdle(cb glib.SourceFunc) {
405+ if hs != nil && hs.idleScheduler != nil {
406+ hs.idleScheduler(cb)
407+ return
408+ }
409+ glib.IdleAdd(&cb, 0)
410+}
411+
412+// scheduleClearList clears the list box on the GTK main thread.
413+func (hs *HistorySidebar) scheduleClearList() {
414+ cb := glib.SourceFunc(func(uintptr) bool {
415+ hs.mu.RLock()
416+ destroyed := hs.destroyed
417+ listBox := hs.listBox
418+ hs.mu.RUnlock()
419+ if destroyed || listBox == nil {
420+ return false
421+ }
422+ listBox.RemoveAll()
423+ return false
424+ })
425+ hs.scheduleIdle(cb)
426+}
427+
428+// scheduleRebuild schedules a list rebuild on the GTK main thread.
429+func (hs *HistorySidebar) scheduleRebuild() {
430+ cb := glib.SourceFunc(func(uintptr) bool {
431+ hs.rebuildList()
432+ return false
433+ })
434+ hs.scheduleIdle(cb)
435+}
436+
437+// setDisplayGroupsLocked updates grouped history and the explicit display-row
438+// model. Caller must hold hs.mu for writing.
439+func (hs *HistorySidebar) setDisplayGroupsLocked(groups []historyGroup) {
440+ hs.groups = groups
441+ hs.displayRows = buildHistoryDisplayRows(groups)
442+}
A internal/ui/component/history_sidebar_rendering.go +234 -0
@@ -0,0 +1,234 @@
1+package component
2+
3+import (
4+ "github.com/bnema/puregotk/v4/gtk"
5+ "github.com/bnema/puregotk/v4/pango"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+)
9+
10+// =============================================================================
11+// List rendering
12+// =============================================================================
13+
14+// rebuildList clears and repopulates the list box from current groups.
15+// Must be called on the GTK main thread. Preserves scroll and selection.
16+func (hs *HistorySidebar) rebuildList() {
17+ hs.mu.RLock()
18+ if hs.destroyed || hs.listBox == nil {
19+ hs.mu.RUnlock()
20+ return
21+ }
22+ rows := hs.displayRows
23+ query := hs.currentQuery
24+ hasSearchResults := hs.searchResults != nil
25+ searchDone := hs.searchDone
26+ totalLoaded := hs.totalLoaded
27+ hs.mu.RUnlock()
28+
29+ // Remove all rows
30+ hs.listBox.RemoveAll()
31+
32+ if len(rows) == 0 {
33+ if !hasSearchResults && totalLoaded == 0 {
34+ // Browse has not loaded yet AND no search has completed.
35+ hs.showLoadingOrEmpty(query, searchDone)
36+ return
37+ }
38+ // Search completed with 0 results, or browse loaded but empty (no history).
39+ hs.showEmptyState(query)
40+ hs.restoreScrollAndSelection()
41+ return
42+ }
43+
44+ for _, row := range rows {
45+ switch row.Kind {
46+ case historyDisplayRowHeader:
47+ hs.appendGroupHeader(row.Label)
48+ case historyDisplayRowEntry:
49+ hs.appendEntryRow(row.Entry)
50+ }
51+ }
52+
53+ hs.listBox.Show()
54+
55+ // Restore previous scroll position and selection
56+ hs.restoreScrollAndSelection()
57+
58+ // If no selection was restored and this is the first load, select first entry
59+ hs.ensureAtLeastOneSelection()
60+}
61+
62+func (hs *HistorySidebar) showLoadingOrEmpty(query string, searchDone bool) {
63+ label := gtk.NewLabel(nil)
64+ if label == nil {
65+ return
66+ }
67+ label.AddCssClass("history-sidebar-loading")
68+
69+ hs.mu.RLock()
70+ isLoading := hs.isLoading
71+ hs.mu.RUnlock()
72+
73+ switch {
74+ case isLoading && query == "":
75+ label.SetText("Loading history...")
76+ case query != "" && !searchDone:
77+ label.SetText("Searching...")
78+ case query != "":
79+ label.SetText(noResultsText(query))
80+ default:
81+ label.SetText("No browsing history")
82+ }
83+
84+ label.SetWrap(false)
85+ label.SetXalign(0.0)
86+
87+ row := gtk.NewListBoxRow()
88+ if row == nil {
89+ return
90+ }
91+ row.SetSelectable(false)
92+ row.SetCanFocus(false)
93+ row.SetActivatable(false)
94+ row.SetChild(&label.Widget)
95+ hs.listBox.Append(&row.Widget)
96+}
97+
98+func noResultsText(query string) string {
99+ return "No results for \"" + query + "\""
100+}
101+
102+func (hs *HistorySidebar) showEmptyState(query string) {
103+ label := gtk.NewLabel(nil)
104+ if label == nil {
105+ return
106+ }
107+ label.AddCssClass("history-sidebar-empty")
108+
109+ if query != "" {
110+ label.SetText(noResultsText(query))
111+ } else {
112+ label.SetText("No browsing history")
113+ }
114+
115+ label.SetWrap(false)
116+ label.SetXalign(0.0)
117+
118+ row := gtk.NewListBoxRow()
119+ if row == nil {
120+ return
121+ }
122+ row.SetSelectable(false)
123+ row.SetCanFocus(false)
124+ row.SetActivatable(false)
125+ row.SetChild(&label.Widget)
126+ hs.listBox.Append(&row.Widget)
127+}
128+
129+// appendGroupHeader adds a non-selectable group header label to the list.
130+func (hs *HistorySidebar) appendGroupHeader(labelText string) {
131+ label := gtk.NewLabel(&labelText)
132+ if label == nil {
133+ return
134+ }
135+ label.AddCssClass("history-sidebar-group-header")
136+ label.SetXalign(0.0)
137+ label.SetHexpand(true)
138+
139+ row := gtk.NewListBoxRow()
140+ if row == nil {
141+ return
142+ }
143+ row.SetSelectable(false)
144+ row.SetCanFocus(false)
145+ row.SetActivatable(false)
146+ row.SetChild(&label.Widget)
147+ hs.listBox.Append(&row.Widget)
148+}
149+
150+// appendEntryRow adds a selectable two-line entry row to the list.
151+func (hs *HistorySidebar) appendEntryRow(entry *entity.HistoryEntry) {
152+ if entry == nil {
153+ return
154+ }
155+ // Outer vertical box for two-line layout
156+ rowBox := gtk.NewBox(gtk.OrientationVerticalValue, 1)
157+ if rowBox == nil {
158+ return
159+ }
160+ rowBox.SetHexpand(true)
161+
162+ // Title line (first line)
163+ titleLabel := gtk.NewLabel(nil)
164+ if titleLabel == nil {
165+ return
166+ }
167+ titleLabel.AddCssClass("history-sidebar-row-title")
168+ titleLabel.SetText(safeSidebarString(entry.Title, entry.URL))
169+ titleLabel.SetXalign(0.0)
170+ titleLabel.SetHexpand(true)
171+ titleLabel.SetEllipsize(pango.EllipsizeEndValue)
172+
173+ // Subtitle line with URL and time
174+ subBox := gtk.NewBox(gtk.OrientationHorizontalValue, 0)
175+ if subBox == nil {
176+ return
177+ }
178+ subBox.SetHexpand(true)
179+
180+ urlLabel := gtk.NewLabel(nil)
181+ if urlLabel == nil {
182+ return
183+ }
184+ urlLabel.AddCssClass("history-sidebar-row-subtitle")
185+ urlLabel.SetText(readableURL(entry.URL))
186+ urlLabel.SetXalign(0.0)
187+ urlLabel.SetHexpand(true)
188+ urlLabel.SetEllipsize(pango.EllipsizeEndValue)
189+
190+ timeLabel := gtk.NewLabel(nil)
191+ if timeLabel == nil {
192+ return
193+ }
194+ timeLabel.AddCssClass("history-sidebar-row-time")
195+ timeLabel.SetText(relativeTime(entry.LastVisited))
196+ timeLabel.SetXalign(1.0)
197+
198+ subBox.Append(&urlLabel.Widget)
199+ subBox.Append(&timeLabel.Widget)
200+
201+ rowBox.Append(&titleLabel.Widget)
202+ rowBox.Append(&subBox.Widget)
203+
204+ // Create the list box row
205+ row := gtk.NewListBoxRow()
206+ if row == nil {
207+ return
208+ }
209+ row.AddCssClass("history-sidebar-row")
210+ row.SetSelectable(true)
211+ row.SetActivatable(true)
212+ row.SetCanFocus(true)
213+ row.SetFocusOnClick(true)
214+ row.SetChild(&rowBox.Widget)
215+
216+ hs.listBox.Append(&row.Widget)
217+}
218+
219+// ensureAtLeastOneSelection selects the first selectable row if nothing is selected.
220+func (hs *HistorySidebar) ensureAtLeastOneSelection() {
221+ if hs.listBox.GetSelectedRow() != nil {
222+ return
223+ }
224+ for i := 0; ; i++ {
225+ row := hs.listBox.GetRowAtIndex(i)
226+ if row == nil {
227+ break
228+ }
229+ if row.GetSelectable() {
230+ hs.listBox.SelectRow(row)
231+ return
232+ }
233+ }
234+}
A internal/ui/component/history_sidebar_search_test.go +391 -0
@@ -0,0 +1,391 @@
1+package component
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/dumber/internal/application/dto"
12+ "github.com/bnema/dumber/internal/domain/entity"
13+ "github.com/bnema/puregotk/v4/glib"
14+ "github.com/rs/zerolog"
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
17+)
18+
19+// =============================================================================
20+// applySearchResults seam tests
21+// =============================================================================
22+
23+// newTestSidebarSearchHarness creates a minimal HistorySidebar with only the
24+// fields needed by applySearchResults, avoiding GTK widget construction.
25+func newTestSidebarSearchHarness() *HistorySidebar {
26+ return &HistorySidebar{
27+ mu: sync.RWMutex{},
28+ logger: zerolog.Nop(),
29+ }
30+}
31+
32+func TestApplySearchResults_NonStaleApplied(t *testing.T) {
33+ hs := newTestSidebarSearchHarness()
34+ hs.searchGen = 1
35+
36+ entries := []*entity.HistoryEntry{
37+ {ID: 1, URL: "https://example.com", Title: "Example", LastVisited: time.Now()},
38+ }
39+
40+ applied := hs.applySearchResults(entries, 1, nil)
41+ assert.True(t, applied, "non-stale results must be applied")
42+ assert.True(t, hs.searchDone)
43+ require.NoError(t, hs.searchErr)
44+ require.NotNil(t, hs.searchResults)
45+ assert.Len(t, hs.searchResults, 1)
46+ assert.Equal(t, "https://example.com", hs.searchResults[0].URL)
47+ require.NotNil(t, hs.groups, "groups must be built from results")
48+ assert.Equal(t, "Today", hs.groups[0].Label)
49+}
50+
51+func TestApplySearchResults_StaleGenRejected(t *testing.T) {
52+ hs := newTestSidebarSearchHarness()
53+ hs.searchGen = 2 // Gen has moved on
54+
55+ entries := []*entity.HistoryEntry{
56+ {ID: 1, URL: "https://stale.com", Title: "Stale", LastVisited: time.Now()},
57+ }
58+
59+ applied := hs.applySearchResults(entries, 1, nil) // gen=1 is stale
60+ assert.False(t, applied, "stale results must be rejected")
61+ assert.False(t, hs.searchDone, "search must not be marked done for stale result")
62+ assert.Nil(t, hs.searchResults, "searchResults must remain nil")
63+ assert.Nil(t, hs.groups, "groups must remain nil")
64+}
65+
66+func TestApplySearchResults_StaleAfterIncrement(t *testing.T) {
67+ hs := newTestSidebarSearchHarness()
68+ hs.searchGen = 1
69+
70+ // First result applied as gen=1
71+ firstEntries := []*entity.HistoryEntry{
72+ {ID: 1, URL: "https://first.com", Title: "First", LastVisited: time.Now()},
73+ }
74+ applied := hs.applySearchResults(firstEntries, 1, nil)
75+ assert.True(t, applied)
76+
77+ // Second search starts, gen advances
78+ hs.searchGen = 2
79+
80+ // Stale result from gen=1 arrives
81+ staleEntries := []*entity.HistoryEntry{
82+ {ID: 2, URL: "https://stale.com", Title: "Stale", LastVisited: time.Now()},
83+ }
84+ applied = hs.applySearchResults(staleEntries, 1, nil)
85+ assert.False(t, applied, "stale result must be rejected")
86+
87+ // The original results must be preserved
88+ require.NotNil(t, hs.searchResults)
89+ assert.Len(t, hs.searchResults, 1)
90+ assert.Equal(t, "https://first.com", hs.searchResults[0].URL)
91+}
92+
93+func TestApplySearchResults_ErrorStored(t *testing.T) {
94+ hs := newTestSidebarSearchHarness()
95+ hs.searchGen = 1
96+
97+ wantErr := errors.New("search failed")
98+ entries := []*entity.HistoryEntry{} // empty but not nil
99+
100+ applied := hs.applySearchResults(entries, 1, wantErr)
101+ assert.True(t, applied)
102+ assert.True(t, hs.searchDone)
103+ require.ErrorIs(t, hs.searchErr, wantErr)
104+ require.NotNil(t, hs.searchResults)
105+ assert.Empty(t, hs.searchResults)
106+}
107+
108+func TestApplySearchResults_EmptyResultsApplied(t *testing.T) {
109+ hs := newTestSidebarSearchHarness()
110+ hs.searchGen = 1
111+
112+ applied := hs.applySearchResults([]*entity.HistoryEntry{}, 1, nil)
113+ assert.True(t, applied)
114+ assert.True(t, hs.searchDone)
115+ require.NoError(t, hs.searchErr)
116+ require.NotNil(t, hs.searchResults)
117+ assert.Empty(t, hs.searchResults)
118+ // Groups from empty entries should be nil or empty
119+ assert.Nil(t, hs.groups, "empty entries should produce nil groups")
120+}
121+
122+func TestApplyDeletedEntryLocked_RecomputesBrowseState(t *testing.T) {
123+ now := time.Now()
124+ keepA := &entity.HistoryEntry{ID: 1, URL: "https://keep-a.com", Title: "Keep A", LastVisited: now}
125+ deleteMe := &entity.HistoryEntry{ID: 2, URL: "https://delete-me.com", Title: "Delete Me", LastVisited: now.Add(-time.Minute)}
126+ keepB := &entity.HistoryEntry{ID: 3, URL: "https://keep-b.com", Title: "Keep B", LastVisited: now.Add(-2 * time.Minute)}
127+
128+ hs := newTestSidebarSearchHarness()
129+ hs.allEntries = []*entity.HistoryEntry{keepA, deleteMe, keepB}
130+ hs.totalLoaded = len(hs.allEntries)
131+ hs.loadGen = 7
132+ hs.isLoading = true
133+ hs.loadStarted = true
134+ hs.searchResults = []*entity.HistoryEntry{deleteMe, keepB}
135+ hs.setDisplayGroupsLocked(groupHistoryByDay(hs.allEntries))
136+
137+ hs.mu.Lock()
138+ hs.applyDeletedEntryLocked(deleteMe.ID, keepB.URL)
139+ hs.mu.Unlock()
140+
141+ assert.Equal(t, uint64(8), hs.loadGen, "deletes must invalidate in-flight browse loads")
142+ assert.False(t, hs.isLoading, "delete should clear stale loading state")
143+ assert.False(t, hs.loadStarted, "delete should clear stale loadStarted state")
144+ assert.Equal(t, 2, hs.totalLoaded, "browse pagination offset must track remaining loaded entries")
145+ assert.Equal(t, keepB.URL, hs.prevSelectedURL)
146+ assert.Len(t, hs.allEntries, 2)
147+ assert.Equal(t, []string{keepA.URL, keepB.URL}, []string{hs.allEntries[0].URL, hs.allEntries[1].URL})
148+ assert.Len(t, hs.searchResults, 1)
149+ assert.Equal(t, keepB.URL, hs.searchResults[0].URL)
150+ require.Len(t, hs.groups, 1)
151+ require.Len(t, hs.groups[0].Entries, 2)
152+ require.Len(t, hs.displayRows, 3, "one header plus two remaining entries")
153+ assert.Equal(t, historyDisplayRowHeader, hs.displayRows[0].Kind)
154+ assert.Equal(t, historyDisplayRowEntry, hs.displayRows[1].Kind)
155+ assert.Equal(t, historyDisplayRowEntry, hs.displayRows[2].Kind)
156+}
157+
158+// =============================================================================
159+// Async search seam: controllable history port fake
160+// =============================================================================
161+
162+type fakeHistorySidebarHistory struct {
163+ getRecentFn func(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
164+ searchFn func(ctx context.Context, input dto.HistorySearchInput) (*dto.HistorySearchOutput, error)
165+ deleteFn func(ctx context.Context, id int64) error
166+}
167+
168+func (f *fakeHistorySidebarHistory) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
169+ if f.getRecentFn != nil {
170+ return f.getRecentFn(ctx, limit, offset)
171+ }
172+ return nil, fmt.Errorf("unexpected GetRecent call in fakeHistorySidebarHistory")
173+}
174+
175+func (f *fakeHistorySidebarHistory) Search(ctx context.Context, input dto.HistorySearchInput) (*dto.HistorySearchOutput, error) {
176+ if f.searchFn != nil {
177+ return f.searchFn(ctx, input)
178+ }
179+ return &dto.HistorySearchOutput{Matches: []entity.HistoryMatch{}}, nil
180+}
181+
182+func (f *fakeHistorySidebarHistory) Delete(ctx context.Context, id int64) error {
183+ if f.deleteFn != nil {
184+ return f.deleteFn(ctx, id)
185+ }
186+ return nil
187+}
188+
189+func TestApplySearchResults_StaleGenerationDropsResultsAfterSearch(t *testing.T) {
190+ searchCalled := make(chan struct{}, 1)
191+ idleCalled := make(chan glib.SourceFunc, 1)
192+
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://result.com", Title: "Result", LastVisited: time.Now()}},
198+ }}, nil
199+ },
200+ }
201+
202+ hs := newTestSidebarSearchHarness()
203+ hs.ctx = t.Context()
204+ hs.historyUC = history
205+ hs.searchGen = 1
206+ hs.idleScheduler = func(cb glib.SourceFunc) {
207+ idleCalled <- cb
208+ }
209+
210+ // Start search with gen=1.
211+ hs.doFTSearch("test", 1)
212+ select {
213+ case <-searchCalled:
214+ case <-time.After(time.Second):
215+ t.Fatal("timed out waiting for search use case to be invoked")
216+ }
217+
218+ // Advance gen before the queued UI callback applies results.
219+ hs.mu.Lock()
220+ hs.searchGen = 2
221+ hs.mu.Unlock()
222+
223+ select {
224+ case cb := <-idleCalled:
225+ cb(0)
226+ case <-time.After(time.Second):
227+ t.Fatal("timed out waiting for scheduled idle callback")
228+ }
229+
230+ hs.mu.RLock()
231+ defer hs.mu.RUnlock()
232+ assert.Nil(t, hs.searchResults)
233+ assert.False(t, hs.searchDone)
234+ assert.Nil(t, hs.groups)
235+}
236+
237+func TestApplySearchResults_CurrentGenerationAppliedAfterSearch(t *testing.T) {
238+ searchCalled := make(chan struct{}, 1)
239+ idleCalled := make(chan glib.SourceFunc, 1)
240+
241+ history := &fakeHistorySidebarHistory{
242+ searchFn: func(context.Context, dto.HistorySearchInput) (*dto.HistorySearchOutput, error) {
243+ searchCalled <- struct{}{}
244+ return &dto.HistorySearchOutput{Matches: []entity.HistoryMatch{
245+ {Entry: &entity.HistoryEntry{ID: 1, URL: "https://live.com", Title: "Live", LastVisited: time.Now()}},
246+ }}, nil
247+ },
248+ }
249+
250+ hs := newTestSidebarSearchHarness()
251+ hs.ctx = t.Context()
252+ hs.historyUC = history
253+ hs.searchGen = 1
254+ hs.idleScheduler = func(cb glib.SourceFunc) {
255+ idleCalled <- cb
256+ }
257+
258+ // Start the search and wait for the use case to be invoked.
259+ hs.doFTSearch("live", 1)
260+ select {
261+ case <-searchCalled:
262+ case <-time.After(time.Second):
263+ t.Fatal("timed out waiting for search use case to be invoked")
264+ }
265+
266+ select {
267+ case cb := <-idleCalled:
268+ cb(0)
269+ case <-time.After(time.Second):
270+ t.Fatal("timed out waiting for scheduled idle callback")
271+ }
272+
273+ hs.mu.RLock()
274+ require.NotNil(t, hs.searchResults)
275+ assert.True(t, hs.searchDone)
276+ assert.Len(t, hs.searchResults, 1)
277+ assert.Equal(t, "https://live.com", hs.searchResults[0].URL)
278+ hs.mu.RUnlock()
279+}
280+
281+// =============================================================================
282+// Reload with query preservation
283+// =============================================================================
284+
285+// TestHistorySidebar_ReloadPreservesQuery verifies that Reload preserves the
286+// active query while resetting browse/search state before the refreshed load.
287+func TestHistorySidebar_ReloadPreservesQuery(t *testing.T) {
288+ searchCalled := make(chan struct{}, 1)
289+ history := &fakeHistorySidebarHistory{
290+ searchFn: func(context.Context, dto.HistorySearchInput) (*dto.HistorySearchOutput, error) {
291+ searchCalled <- struct{}{}
292+ return &dto.HistorySearchOutput{Matches: []entity.HistoryMatch{}}, nil
293+ },
294+ }
295+
296+ hs := newTestSidebarSearchHarness()
297+ hs.currentQuery = "preserved"
298+ hs.historyUC = history
299+ hs.ctx = t.Context()
300+
301+ hs.mu.Lock()
302+ oldGen := hs.searchGen
303+ hs.loadDone = true
304+ hs.allEntries = []*entity.HistoryEntry{
305+ {ID: 1, URL: "https://old.com", Title: "Old", LastVisited: time.Now()},
306+ }
307+ hs.groups = groupHistoryByDay(hs.allEntries)
308+ hs.searchResults = []*entity.HistoryEntry{{ID: 2, URL: "https://stale.com", Title: "Stale", LastVisited: time.Now()}}
309+ hs.searchDone = true
310+ hs.mu.Unlock()
311+
312+ hs.Reload()
313+
314+ select {
315+ case <-searchCalled:
316+ case <-time.After(time.Second):
317+ t.Fatal("timed out waiting for reload search to be invoked")
318+ }
319+
320+ hs.mu.RLock()
321+ assert.Equal(t, "preserved", hs.currentQuery, "query must be preserved after Reload")
322+ assert.False(t, hs.loadDone, "loadDone must be reset")
323+ assert.False(t, hs.loadStarted, "loadStarted must be reset")
324+ assert.Nil(t, hs.allEntries, "entries must be cleared")
325+ assert.Nil(t, hs.groups, "groups must be cleared")
326+ assert.Nil(t, hs.searchResults, "searchResults must be cleared before refreshed search applies")
327+ assert.False(t, hs.searchDone, "searchDone must be reset")
328+ assert.Equal(t, oldGen+1, hs.searchGen, "searchGen must be incremented")
329+ hs.mu.RUnlock()
330+}
331+
332+// =============================================================================
333+// fetchPage generation guard: stale browse results must not clear loading state
334+// =============================================================================
335+
336+func TestFetchPage_StaleGenerationDoesNotMutateLoadingState(t *testing.T) {
337+ t.Parallel()
338+
339+ // Repo blocks GetRecent until test signals proceed, so we can control
340+ // when a stale fetch completes relative to generation changes.
341+ getRecentCalled := make(chan struct{})
342+ proceed := make(chan struct{})
343+
344+ history := &fakeHistorySidebarHistory{
345+ getRecentFn: func(context.Context, int, int) ([]*entity.HistoryEntry, error) {
346+ close(getRecentCalled)
347+ <-proceed
348+ return []*entity.HistoryEntry{}, nil
349+ },
350+ }
351+
352+ hs := newTestSidebarSearchHarness()
353+ hs.ctx = t.Context()
354+ hs.historyUC = history
355+
356+ // Simulate: gen 1 started, then gen 2 started (e.g. by Reload/Show).
357+ // The second call incremented loadGen and set isLoading/loadStarted.
358+ hs.mu.Lock()
359+ hs.loadGen = 2
360+ hs.isLoading = true
361+ hs.loadStarted = true
362+ hs.totalLoaded = 0
363+ hs.hasMore = true
364+ hs.mu.Unlock()
365+
366+ // Start stale gen 1 fetch in background
367+ fetchDone := make(chan struct{})
368+ go func() {
369+ hs.fetchPage(0, 1) // gen=1 is stale — loadGen is 2
370+ close(fetchDone)
371+ }()
372+
373+ // Wait until GetRecent is entered (fetchPage is blocked inside the repo call)
374+ <-getRecentCalled
375+
376+ // Let GetRecent return — this simulates the gen 1 fetch completing
377+ // after gen 2 has already taken over.
378+ close(proceed)
379+
380+ // Wait for fetchPage to finish processing the stale return
381+ <-fetchDone
382+
383+ // Verify: stale completion must NOT clear the new generation's loading state
384+ hs.mu.RLock()
385+ assert.True(t, hs.isLoading, "isLoading must remain true when stale gen completes")
386+ assert.True(t, hs.loadStarted, "loadStarted must remain true when stale gen completes")
387+ assert.Equal(t, uint64(2), hs.loadGen, "loadGen must remain unchanged")
388+ assert.False(t, hs.loadDone, "loadDone must remain false; gen 2 hasn't completed")
389+ assert.Equal(t, 0, hs.totalLoaded, "stale results must not update totalLoaded")
390+ hs.mu.RUnlock()
391+}
A internal/ui/component/history_sidebar_widgets.go +93 -0
@@ -0,0 +1,93 @@
1+package component
2+
3+import (
4+ "fmt"
5+
6+ "github.com/bnema/puregotk/v4/gtk"
7+)
8+
9+// =============================================================================
10+// Widget creation
11+// =============================================================================
12+
13+func (hs *HistorySidebar) createWidgets() error {
14+ if err := hs.initOuterBox(); err != nil {
15+ return err
16+ }
17+ if err := hs.initSearchBox(); err != nil {
18+ return err
19+ }
20+ if err := hs.initListArea(); err != nil {
21+ return err
22+ }
23+ return nil
24+}
25+
26+func (hs *HistorySidebar) initOuterBox() error {
27+ hs.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
28+ if hs.outerBox == nil {
29+ return fmt.Errorf("history sidebar: outer box creation failed")
30+ }
31+ hs.outerBox.AddCssClass("history-sidebar-outer")
32+ hs.outerBox.SetSizeRequest(sidebarMinWidth, -1)
33+ hs.outerBox.SetHexpand(false)
34+ hs.outerBox.SetVexpand(true)
35+ hs.outerBox.SetVisible(false)
36+ return nil
37+}
38+
39+func (hs *HistorySidebar) initSearchBox() error {
40+ hs.searchBox = gtk.NewBox(gtk.OrientationHorizontalValue, 4)
41+ if hs.searchBox == nil {
42+ return fmt.Errorf("history sidebar: search box creation failed")
43+ }
44+ hs.searchBox.AddCssClass("history-sidebar-search-box")
45+ hs.searchBox.SetHexpand(true)
46+
47+ hs.searchEntry = gtk.NewSearchEntry()
48+ if hs.searchEntry == nil {
49+ return fmt.Errorf("history sidebar: search entry creation failed")
50+ }
51+ hs.searchEntry.AddCssClass("history-sidebar-search")
52+ hs.searchEntry.SetHexpand(true)
53+ placeholder := "Search history..."
54+ hs.searchEntry.SetPlaceholderText(&placeholder)
55+
56+ hs.searchBox.Append(&hs.searchEntry.Widget)
57+ hs.outerBox.Append(&hs.searchBox.Widget)
58+ return nil
59+}
60+
61+func (hs *HistorySidebar) initListArea() error {
62+ hs.scrolledWin = gtk.NewScrolledWindow()
63+ if hs.scrolledWin == nil {
64+ return fmt.Errorf("history sidebar: scrolled window creation failed")
65+ }
66+ hs.scrolledWin.SetVexpand(true)
67+ hs.scrolledWin.SetHexpand(true)
68+ hs.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
69+ hs.scrolledWin.AddCssClass("history-sidebar-groups")
70+
71+ hs.listBox = gtk.NewListBox()
72+ if hs.listBox == nil {
73+ return fmt.Errorf("history sidebar: list box creation failed")
74+ }
75+ hs.listBox.AddCssClass("history-sidebar-groups")
76+ hs.listBox.SetActivateOnSingleClick(true)
77+ hs.listBox.SetSelectionMode(gtk.SelectionSingleValue)
78+
79+ // Connect row activation (single-click and keyboard activation)
80+ rowActivatedCb := func(_ gtk.ListBox, rowPtr uintptr) {
81+ row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
82+ if row == nil {
83+ return
84+ }
85+ hs.onRowActivated(row)
86+ }
87+ hs.retainedCallbacks = append(hs.retainedCallbacks, rowActivatedCb)
88+ hs.listBox.ConnectRowActivated(&rowActivatedCb)
89+
90+ hs.scrolledWin.SetChild(&hs.listBox.Widget)
91+ hs.outerBox.Append(&hs.scrolledWin.Widget)
92+ return nil
93+}
A internal/ui/component/history_test_helpers_test.go +39 -0
@@ -0,0 +1,39 @@
1+package component
2+
3+// searchStateSnapshot captures the observable state of a history search
4+// at a point in time. This is test-only data for pure transition checks.
5+type searchStateSnapshot struct {
6+ Query string
7+ HasSearchDone bool
8+ HasResults bool
9+ ResultCount int
10+}
11+
12+// transitionSearchState models a search state transition for tests without GTK.
13+func transitionSearchState(newQuery string, resultCount int) searchStateSnapshot {
14+ return searchStateSnapshot{
15+ Query: newQuery,
16+ HasSearchDone: newQuery != "",
17+ HasResults: newQuery != "" && resultCount > 0,
18+ ResultCount: resultCount,
19+ }
20+}
21+
22+// reloadPreservationSnapshot captures the preserved state during a reload.
23+type reloadPreservationSnapshot struct {
24+ PreservedQuery string
25+ ResetBrowse bool
26+ ClearSearch bool
27+}
28+
29+// applyReloadState computes the expected state after a reload for test-only
30+// transition checks.
31+func applyReloadState(currentQuery string) reloadPreservationSnapshot {
32+ s := reloadPreservationSnapshot{PreservedQuery: currentQuery}
33+ if currentQuery == "" {
34+ s.ResetBrowse = true
35+ } else {
36+ s.ClearSearch = true
37+ }
38+ return s
39+}
M internal/ui/dispatcher/keyboard.go +34 -19
@@ -15,6 +15,10 @@ import (
15 )15 )
16 16
17 const (17 const (
18+ // historySystemViewURL is the full-page/systemview history surface.
19+ // It remains reachable by direct navigation (for example typing
20+ // dumb://history in the omnibox), but Ctrl+H no longer falls back to it.
21+ // Ctrl+H is reserved for the native GTK history sidebar only.
18 historySystemViewURL = "dumb://history"22 historySystemViewURL = "dumb://history"
19 favoritesSystemViewURL = "dumb://favorites"23 favoritesSystemViewURL = "dumb://favorites"
20 configSystemViewURL = "dumb://config"24 configSystemViewURL = "dumb://config"
@@ -32,24 +36,25 @@ type KeyboardActions struct {
32 36
33 // KeyboardDispatcher routes keyboard actions to appropriate coordinators.37 // KeyboardDispatcher routes keyboard actions to appropriate coordinators.
34 type KeyboardDispatcher struct {38 type KeyboardDispatcher struct {
35- actions KeyboardActions39+ actions KeyboardActions
36- wsCoord *coordinator.WorkspaceCoordinator40+ wsCoord *coordinator.WorkspaceCoordinator
37- navCoord *coordinator.NavigationCoordinator41+ navCoord *coordinator.NavigationCoordinator
38- zoomUC *usecase.ManageZoomUseCase42+ zoomUC *usecase.ManageZoomUseCase
39- copyURLUC *usecase.CopyURLUseCase43+ copyURLUC *usecase.CopyURLUseCase
40- actionHandlers map[input.Action]func(ctx context.Context) error44+ actionHandlers map[input.Action]func(ctx context.Context) error
41- onQuit func()45+ onQuit func()
42- onFindOpen func(ctx context.Context) error46+ onFindOpen func(ctx context.Context) error
43- onFindNext func(ctx context.Context) error47+ onFindNext func(ctx context.Context) error
44- onFindPrev func(ctx context.Context) error48+ onFindPrev func(ctx context.Context) error
45- onFindClose func(ctx context.Context) error49+ onFindClose func(ctx context.Context) error
46- activePaneID func(ctx context.Context) entity.PaneID50+ activePaneID func(ctx context.Context) entity.PaneID
47- onSessionOpen func(ctx context.Context, paneID entity.PaneID) error51+ onSessionOpen func(ctx context.Context, paneID entity.PaneID) error
48- onMovePaneToTab func(ctx context.Context, paneID entity.PaneID) error52+ onMovePaneToTab func(ctx context.Context, paneID entity.PaneID) error
49- onMovePaneToNext func(ctx context.Context, paneID entity.PaneID) error53+ onMovePaneToNext func(ctx context.Context, paneID entity.PaneID) error
50- onEjectPaneToWindow func(ctx context.Context, paneID entity.PaneID) error54+ onEjectPaneToWindow func(ctx context.Context, paneID entity.PaneID) error
51- onToggleFloating func(ctx context.Context) error55+ onToggleHistorySidebar func(ctx context.Context) error
52- onOpenFloating func(ctx context.Context, target input.FloatingProfileTarget) error56+ onToggleFloating func(ctx context.Context) error
57+ onOpenFloating func(ctx context.Context, target input.FloatingProfileTarget) error
53 }58 }
54 59
55 // NewKeyboardDispatcher creates a new KeyboardDispatcher.60 // NewKeyboardDispatcher creates a new KeyboardDispatcher.
@@ -119,6 +124,10 @@ func (d *KeyboardDispatcher) SetOnEjectPaneToWindow(fn func(ctx context.Context,
119 d.onEjectPaneToWindow = fn124 d.onEjectPaneToWindow = fn
120 }125 }
121 126
127+func (d *KeyboardDispatcher) SetOnToggleHistorySidebar(fn func(ctx context.Context) error) {
128+ d.onToggleHistorySidebar = fn
129+}
130+
122 func (d *KeyboardDispatcher) SetOnToggleFloatingPane(fn func(ctx context.Context) error) {131 func (d *KeyboardDispatcher) SetOnToggleFloatingPane(fn func(ctx context.Context) error) {
123 d.onToggleFloating = fn132 d.onToggleFloating = fn
124 }133 }
@@ -243,8 +252,14 @@ func (d *KeyboardDispatcher) initActionHandlers() {
243 }252 }
244 return d.logNoop(ctx, "toggle floating pane action (no handler)")253 return d.logNoop(ctx, "toggle floating pane action (no handler)")
245 },254 },
255+ // ActionToggleHistorySystemView (default Ctrl+H) is intentionally bound
256+ // to the native GTK history sidebar only. The dumb://history systemview
257+ // remains available through direct navigation, not shortcut fallback.
246 input.ActionToggleHistorySystemView: func(ctx context.Context) error {258 input.ActionToggleHistorySystemView: func(ctx context.Context) error {
247- return d.wsCoord.ToggleSystemViewRight(ctx, historySystemViewURL)259+ if d.onToggleHistorySidebar == nil {
260+ return fmt.Errorf("history sidebar unavailable: toggle handler not wired")
261+ }
262+ return d.onToggleHistorySidebar(ctx)
248 },263 },
249 input.ActionToggleFavoritesSystemView: func(ctx context.Context) error {264 input.ActionToggleFavoritesSystemView: func(ctx context.Context) error {
250 return d.wsCoord.ToggleSystemViewRight(ctx, favoritesSystemViewURL)265 return d.wsCoord.ToggleSystemViewRight(ctx, favoritesSystemViewURL)
M internal/ui/dispatcher/keyboard_test.go +44 -26
@@ -2,11 +2,10 @@ package dispatcher
2 2
3 import (3 import (
4 "context"4 "context"
5+ "fmt"
5 "testing"6 "testing"
6 7
7- "github.com/bnema/dumber/internal/application/usecase"
8 "github.com/bnema/dumber/internal/domain/entity"8 "github.com/bnema/dumber/internal/domain/entity"
9- "github.com/bnema/dumber/internal/ui/component"
10 "github.com/bnema/dumber/internal/ui/coordinator"9 "github.com/bnema/dumber/internal/ui/coordinator"
11 "github.com/bnema/dumber/internal/ui/input"10 "github.com/bnema/dumber/internal/ui/input"
12 "github.com/stretchr/testify/assert"11 "github.com/stretchr/testify/assert"
@@ -64,37 +63,56 @@ func TestKeyboardDispatcher_TabActionsUseInjectedKeyboardActions(t *testing.T) {
64 assert.Equal(t, 4, switchedIndex)63 assert.Equal(t, 4, switchedIndex)
65 }64 }
66 65
67-func TestKeyboardDispatcher_ToggleHistorySystemViewOpensRightSplit(t *testing.T) {66+func TestKeyboardDispatcher_ToggleHistorySidebarCallsCallback(t *testing.T) {
68 ctx := context.Background()67 ctx := context.Background()
69- ids := []string{"pane-2", "split-1"}68+ d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, KeyboardActions{}, func(context.Context) entity.PaneID { return "" })
70- idx := 0
71- panesUC := usecase.NewManagePanesUseCase(func() string {
72- id := ids[idx]
73- idx++
74- return id
75- })
76 69
77- initialPane := entity.NewPane("pane-1")70+ var called bool
78- initialPane.URI = "https://example.com"71+ d.SetOnToggleHistorySidebar(func(context.Context) error {
79- ws := entity.NewWorkspace("ws-1", initialPane)72+ called = true
80- wsCoord := coordinator.NewWorkspaceCoordinator(ctx, coordinator.WorkspaceCoordinatorConfig{73+ return nil
81- PanesUC: panesUC,
82- GetActiveWS: func() (*entity.Workspace, *component.WorkspaceView) {
83- return ws, nil
84- },
85 })74 })
86 75
87- d := NewKeyboardDispatcher(ctx, wsCoord, &coordinator.NavigationCoordinator{}, nil, nil, KeyboardActions{}, func(context.Context) entity.PaneID { return "" })
88-
89 err := d.Dispatch(ctx, input.ActionToggleHistorySystemView)76 err := d.Dispatch(ctx, input.ActionToggleHistorySystemView)
90 require.NoError(t, err)77 require.NoError(t, err)
78+ assert.True(t, called, "onToggleHistorySidebar should have been called")
79+}
80+
81+func TestKeyboardDispatcher_ToggleHistorySystemViewReturnsErrorWhenHandlerMissing(t *testing.T) {
82+ ctx := context.Background()
83+ d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, KeyboardActions{}, func(context.Context) entity.PaneID { return "" })
84+
85+ err := d.Dispatch(ctx, input.ActionToggleHistorySystemView)
86+ require.Error(t, err)
87+ assert.ErrorContains(t, err, "history sidebar unavailable")
88+}
89+
90+func TestKeyboardDispatcher_ToggleHistorySidebarErrorPropagation(t *testing.T) {
91+ ctx := context.Background()
92+ d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, KeyboardActions{}, func(context.Context) entity.PaneID { return "" })
91 93
92- require.Equal(t, 2, ws.PaneCount())94+ wantErr := fmt.Errorf("sidebar toggle failed")
93- active := ws.ActivePane()95+ d.SetOnToggleHistorySidebar(func(context.Context) error {
94- require.NotNil(t, active)96+ return wantErr
95- require.NotNil(t, active.Pane)97+ })
96- assert.Equal(t, entity.PaneID("pane-2"), active.Pane.ID)98+
97- assert.Equal(t, "dumb://history", active.Pane.URI)99+ err := d.Dispatch(ctx, input.ActionToggleHistorySystemView)
100+ require.Error(t, err)
101+ assert.ErrorIs(t, err, wantErr, "onToggleHistorySidebar error should propagate")
102+}
103+
104+func TestKeyboardDispatcher_ToggleHistorySidebarSetThenUnsetReturnsError(t *testing.T) {
105+ ctx := context.Background()
106+ d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, KeyboardActions{}, func(context.Context) entity.PaneID { return "" })
107+
108+ d.SetOnToggleHistorySidebar(func(context.Context) error {
109+ return nil
110+ })
111+ d.SetOnToggleHistorySidebar(nil)
112+
113+ err := d.Dispatch(ctx, input.ActionToggleHistorySystemView)
114+ require.Error(t, err)
115+ assert.ErrorContains(t, err, "history sidebar unavailable")
98 }116 }
99 117
100 func TestKeyboardDispatcher_PassesActivePaneIDToShellCallbacks(t *testing.T) {118 func TestKeyboardDispatcher_PassesActivePaneIDToShellCallbacks(t *testing.T) {
M internal/ui/theme/css.go +4 -0
@@ -140,6 +140,10 @@ func GenerateCSSFull(p Palette, _ float64, fonts FontConfig, modeColors ModeColo
140 140
141 // Accent picker styling141 // Accent picker styling
142 sb.WriteString(generateAccentPickerCSS(p))142 sb.WriteString(generateAccentPickerCSS(p))
143+ sb.WriteString("\n")
144+
145+ // History sidebar styling
146+ sb.WriteString(generateHistorySidebarCSS(p))
143 147
144 return sb.String()148 return sb.String()
145 }149 }
A internal/ui/theme/history_sidebar_css.go +90 -0
@@ -0,0 +1,90 @@
1+package theme
2+
3+// generateHistorySidebarCSS creates GTK4 CSS for the history sidebar component.
4+func generateHistorySidebarCSS(_ Palette) string {
5+ return `/* ===== History Sidebar Styling ===== */
6+
7+.history-sidebar-outer {
8+ background-color: var(--surface);
9+ border-left: 0.0625em solid var(--border);
10+}
11+
12+.history-sidebar-search-box {
13+ padding: 0.375em 0.5em;
14+ border-bottom: 0.0625em solid var(--border);
15+ background-color: var(--surface);
16+}
17+
18+.history-sidebar-search {
19+ padding: 0.125em 0.375em;
20+ font-size: 0.85em;
21+}
22+
23+.history-sidebar-groups {
24+ background-color: var(--surface);
25+}
26+
27+.history-sidebar-group-header {
28+ padding: 0.25em 0.625em;
29+ padding-top: 0.375em;
30+ font-size: 0.75em;
31+ font-weight: 600;
32+ color: var(--muted);
33+ text-transform: uppercase;
34+ letter-spacing: 0.04em;
35+ background-color: var(--surface-variant);
36+ border-bottom: 0.0625em solid var(--border);
37+}
38+
39+.history-sidebar-row {
40+ padding: 0.1875em 0.625em;
41+ min-height: 0;
42+ border-bottom: 0.0625em solid alpha(var(--border), 0.4);
43+ background-color: var(--surface);
44+ transition: background-color 100ms ease;
45+}
46+
47+.history-sidebar-row:hover {
48+ background-color: alpha(var(--accent), 0.18);
49+}
50+
51+.history-sidebar-row:selected {
52+ background-color: alpha(var(--accent), 0.18);
53+}
54+
55+.history-sidebar-row:focus {
56+ background-color: alpha(var(--accent), 0.18);
57+}
58+
59+.history-sidebar-row-title {
60+ font-size: 0.82em;
61+ color: var(--text);
62+ font-weight: 500;
63+}
64+
65+.history-sidebar-row-subtitle {
66+ font-size: 0.72em;
67+ color: var(--muted);
68+}
69+
70+.history-sidebar-row-time {
71+ font-size: 0.68em;
72+ color: var(--muted);
73+ padding-left: 0.5em;
74+ opacity: 0.75;
75+}
76+
77+.history-sidebar-empty {
78+ padding: 1.5em 0.75em;
79+ font-size: 0.82em;
80+ color: var(--muted);
81+ font-style: italic;
82+}
83+
84+.history-sidebar-loading {
85+ padding: 1.5em 0.75em;
86+ font-size: 0.82em;
87+ color: var(--muted);
88+}
89+`
90+}
A internal/ui/theme/history_sidebar_css_test.go +333 -0
@@ -0,0 +1,333 @@
1+package theme
2+
3+import (
4+ "crypto/sha256"
5+ "strings"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+)
10+
11+func TestGenerateHistorySidebarCSS_ContainsExpectedSelectors(t *testing.T) {
12+ css := generateHistorySidebarCSS(DefaultDarkPalette())
13+
14+ selectors := []string{
15+ ".history-sidebar-outer",
16+ ".history-sidebar-search-box",
17+ ".history-sidebar-search",
18+ ".history-sidebar-groups",
19+ ".history-sidebar-group-header",
20+ ".history-sidebar-row",
21+ ".history-sidebar-row:hover",
22+ ".history-sidebar-row:selected",
23+ ".history-sidebar-row:focus",
24+ ".history-sidebar-row-title",
25+ ".history-sidebar-row-subtitle",
26+ ".history-sidebar-row-time",
27+ ".history-sidebar-empty",
28+ ".history-sidebar-loading",
29+ }
30+
31+ for _, sel := range selectors {
32+ assert.Contains(t, css, sel+" {",
33+ "expected selector %s { in generated CSS", sel)
34+ }
35+}
36+
37+func TestGenerateHistorySidebarCSS_AccentAlphaInterpolation(t *testing.T) {
38+ css := generateHistorySidebarCSS(DefaultDarkPalette())
39+
40+ expectedAlpha := "alpha(var(--accent), 0.18)"
41+ assert.Contains(t, css, expectedAlpha,
42+ "expected accent alpha variable usage %q in generated CSS", expectedAlpha)
43+ assert.NotContains(t, css, "alpha(#",
44+ "history sidebar CSS should not inline hardcoded accent hex values")
45+
46+ // The accent alpha should appear in hover, selected, and focus blocks.
47+ assert.GreaterOrEqual(t, strings.Count(css, expectedAlpha), 3,
48+ "expected accent alpha to appear at least 3 times (hover/selected/focus)")
49+}
50+
51+func TestGenerateHistorySidebarCSS_DeterministicOutput(t *testing.T) {
52+ palette := DefaultDarkPalette()
53+
54+ css1 := generateHistorySidebarCSS(palette)
55+ css2 := generateHistorySidebarCSS(palette)
56+
57+ hash1 := sha256.Sum256([]byte(css1))
58+ hash2 := sha256.Sum256([]byte(css2))
59+
60+ assert.Equal(t, hash1, hash2, "CSS output must be deterministic for the same palette")
61+}
62+
63+func TestGenerateHistorySidebarCSS_UsesPaletteVariablesInsteadOfInliningColors(t *testing.T) {
64+ darkCSS := generateHistorySidebarCSS(DefaultDarkPalette())
65+ lightCSS := generateHistorySidebarCSS(DefaultLightPalette())
66+
67+ assert.Equal(t, darkCSS, lightCSS,
68+ "history sidebar fragment should be palette-independent when it relies on CSS variables")
69+ assert.Contains(t, darkCSS, "alpha(var(--accent), 0.18)")
70+ assert.NotContains(t, darkCSS, "alpha(#")
71+}
72+
73+func TestGenerateHistorySidebarCSS_ThroughGenerateCSS(t *testing.T) {
74+ fullCSS := GenerateCSS(DefaultDarkPalette())
75+
76+ assert.Contains(t, fullCSS, ".history-sidebar-outer {",
77+ "full CSS must contain history sidebar selectors")
78+ assert.Contains(t, fullCSS, ".history-sidebar-group-header {",
79+ "full CSS must contain group header selector")
80+ assert.Contains(t, fullCSS, ".history-sidebar-row-title {",
81+ "full CSS must contain row title selector")
82+ assert.Contains(t, fullCSS, "History Sidebar Styling",
83+ "full CSS must contain the history sidebar comment marker")
84+}
85+
86+func TestGenerateHistorySidebarCSS_NoEmptyCSSBlocks(t *testing.T) {
87+ css := generateHistorySidebarCSS(DefaultDarkPalette())
88+
89+ sections := strings.Split(css, "{")
90+ for i, section := range sections {
91+ if i == 0 {
92+ continue
93+ }
94+ closeIdx := strings.Index(section, "}")
95+ if closeIdx < 0 {
96+ continue
97+ }
98+ blockContent := strings.TrimSpace(section[:closeIdx])
99+ assert.NotEmpty(t, blockContent, "CSS block must not be empty: section %d", i)
100+ }
101+}
102+
103+func TestGenerateHistorySidebarCSS_CustomPaletteValuesInterpolated(t *testing.T) {
104+ customPalette := Palette{
105+ Background: "#111111",
106+ Surface: "#222222",
107+ SurfaceVariant: "#333333",
108+ Text: "#eeeeee",
109+ Muted: "#aaaaaa",
110+ Accent: "#ff6600",
111+ Border: "#444444",
112+ Success: "#00cc44",
113+ Warning: "#ffaa00",
114+ Destructive: "#cc2222",
115+ }
116+
117+ css := generateHistorySidebarCSS(customPalette)
118+
119+ assert.Contains(t, css, "alpha(var(--accent), 0.18)",
120+ "sidebar CSS should use the shared accent variable")
121+ assert.NotContains(t, css, "#ff6600",
122+ "sidebar fragment should not inline palette-specific accent values")
123+
124+ assert.Contains(t, css, ".history-sidebar-outer {")
125+ assert.Contains(t, css, ".history-sidebar-row-title {")
126+ assert.Contains(t, css, ".history-sidebar-row-subtitle {")
127+ assert.Contains(t, css, ".history-sidebar-row-time {")
128+ assert.Contains(t, css, ".history-sidebar-empty {")
129+}
130+
131+func TestGenerateHistorySidebarCSS_ContainsTransition(t *testing.T) {
132+ css := generateHistorySidebarCSS(DefaultDarkPalette())
133+
134+ assert.Contains(t, css, "transition:",
135+ "CSS should include transition property for smooth hover effects")
136+ assert.Contains(t, css, "background-color 100ms ease",
137+ "row transition should specify background-color 100ms ease")
138+}
139+
140+func TestGenerateHistorySidebarCSS_ContainsUppercaseGroupHeader(t *testing.T) {
141+ css := generateHistorySidebarCSS(DefaultDarkPalette())
142+
143+ assert.Contains(t, css, "text-transform: uppercase;",
144+ "group header should use text-transform uppercase")
145+ assert.Contains(t, css, "letter-spacing: 0.04em;",
146+ "group header should have letter-spacing")
147+}
148+
149+func TestGenerateHistorySidebarCSS_InGenerateCSSFull_DarkPalette(t *testing.T) {
150+ fullCSS := GenerateCSSFull(DefaultDarkPalette(), 1.0, DefaultFontConfig(), DefaultModeColors())
151+
152+ assert.Contains(t, fullCSS, ".history-sidebar-outer {",
153+ "GenerateCSSFull must include history sidebar outer selector for dark palette")
154+ assert.Contains(t, fullCSS, ".history-sidebar-row-title {",
155+ "GenerateCSSFull must include history sidebar row title selector for dark palette")
156+ assert.Contains(t, fullCSS, ".history-sidebar-search {",
157+ "GenerateCSSFull must include history sidebar search selector for dark palette")
158+ assert.Contains(t, fullCSS, ".history-sidebar-empty {",
159+ "GenerateCSSFull must include history sidebar empty selector for dark palette")
160+}
161+
162+func TestGenerateHistorySidebarCSS_InGenerateCSSFull_LightPalette(t *testing.T) {
163+ fullCSS := GenerateCSSFull(DefaultLightPalette(), 1.0, DefaultFontConfig(), DefaultModeColors())
164+
165+ assert.Contains(t, fullCSS, ".history-sidebar-outer {",
166+ "GenerateCSSFull must include history sidebar outer selector for light palette")
167+ assert.Contains(t, fullCSS, ".history-sidebar-row-title {",
168+ "GenerateCSSFull must include history sidebar row title selector for light palette")
169+ assert.Contains(t, fullCSS, ".history-sidebar-search {",
170+ "GenerateCSSFull must include history sidebar search selector for light palette")
171+ assert.Contains(t, fullCSS, ".history-sidebar-empty {",
172+ "GenerateCSSFull must include history sidebar empty selector for light palette")
173+}
174+
175+func TestGenerateHistorySidebarCSS_LiveReloadSeamIncludesUpdatedCSS(t *testing.T) {
176+ // GenerateCSSFull is the live theme reload entry point called when
177+ // the palette changes at runtime. This test proves that switching
178+ // palettes produces different history sidebar CSS, confirming the
179+ // full generation path includes and re-generates the sidebar styles.
180+ darkCSS := GenerateCSSFull(DefaultDarkPalette(), 1.0, DefaultFontConfig(), DefaultModeColors())
181+ lightCSS := GenerateCSSFull(DefaultLightPalette(), 1.0, DefaultFontConfig(), DefaultModeColors())
182+
183+ // Both must contain history sidebar selectors.
184+ assert.Contains(t, darkCSS, ".history-sidebar-outer {", "dark CSS must have sidebar styles")
185+ assert.Contains(t, lightCSS, ".history-sidebar-outer {", "light CSS must have sidebar styles")
186+
187+ // The CSS must differ between palettes (different color values).
188+ if darkCSS == lightCSS {
189+ t.Fatal("dark and light palette should produce different CSS when history sidebar is included")
190+ }
191+
192+ // The dark and light outputs must both be parseable: each '{' has '}'.
193+ assert.Equal(t, strings.Count(darkCSS, "{"), strings.Count(darkCSS, "}"),
194+ "dark CSS braces must be balanced")
195+ assert.Equal(t, strings.Count(lightCSS, "{"), strings.Count(lightCSS, "}"),
196+ "light CSS braces must be balanced")
197+}
198+
199+func TestGenerateHistorySidebarCSS_InGenerateCSSFull_WithCustomScaleAndFonts(t *testing.T) {
200+ customFonts := FontConfig{
201+ SansFont: "Noto Sans",
202+ MonospaceFont: "JetBrains Mono",
203+ GtkFont: "Noto Sans",
204+ }
205+ customModeColors := ModeColors{
206+ PaneMode: "#ff0000",
207+ TabMode: "#00ff00",
208+ SessionMode: "#0000ff",
209+ ResizeMode: "#ffff00",
210+ }
211+
212+ fullCSS := GenerateCSSFull(DefaultDarkPalette(), 2.0, customFonts, customModeColors)
213+
214+ assert.Contains(t, fullCSS, ".history-sidebar-outer {",
215+ "GenerateCSSFull with custom scale/fonts must include history sidebar selectors")
216+ assert.Contains(t, fullCSS, ".history-sidebar-row:hover {",
217+ "GenerateCSSFull must include hover state for history rows")
218+ assert.Contains(t, fullCSS, ".history-sidebar-row:selected {",
219+ "GenerateCSSFull must include selected state for history rows")
220+ assert.Contains(t, fullCSS, ".history-sidebar-row:focus {",
221+ "GenerateCSSFull must include focus state for history rows")
222+}
223+
224+// TestGenerateHistorySidebarCSS_LiveReloadFullPath verifies that the
225+// GenerateCSSFull entry point (used by live theme reload) includes the
226+// history sidebar CSS with correct palette values. This is the path that
227+// would be called when the user changes themes at runtime.
228+func TestGenerateHistorySidebarCSS_LiveReloadFullPath(t *testing.T) {
229+ darkPalette := DefaultDarkPalette()
230+ lightPalette := DefaultLightPalette()
231+ customPalette := Palette{
232+ Background: "#111111",
233+ Surface: "#222222",
234+ SurfaceVariant: "#333333",
235+ Text: "#eeeeee",
236+ Muted: "#aaaaaa",
237+ Accent: "#ff6600",
238+ Border: "#444444",
239+ Success: "#00cc44",
240+ Warning: "#ffaa00",
241+ Destructive: "#cc2222",
242+ }
243+
244+ // GenerateCSSFull is the exact function called by the live theme reload
245+ // path. It generates ALL CSS including the history sidebar selectors.
246+ darkCSS := GenerateCSSFull(darkPalette, 1.0, DefaultFontConfig(), DefaultModeColors())
247+ lightCSS := GenerateCSSFull(lightPalette, 1.0, DefaultFontConfig(), DefaultModeColors())
248+ customCSS := GenerateCSSFull(customPalette, 1.0, DefaultFontConfig(), DefaultModeColors())
249+
250+ // All three must contain the full set of history sidebar selectors.
251+ sidebarSelectors := []string{
252+ ".history-sidebar-outer",
253+ ".history-sidebar-search-box",
254+ ".history-sidebar-search",
255+ ".history-sidebar-groups",
256+ ".history-sidebar-group-header",
257+ ".history-sidebar-row",
258+ ".history-sidebar-row:hover",
259+ ".history-sidebar-row:selected",
260+ ".history-sidebar-row:focus",
261+ ".history-sidebar-row-title",
262+ ".history-sidebar-row-subtitle",
263+ ".history-sidebar-row-time",
264+ ".history-sidebar-empty",
265+ ".history-sidebar-loading",
266+ }
267+
268+ for _, sel := range sidebarSelectors {
269+ assert.Contains(t, darkCSS, sel+" {", "dark CSS must have selector %s", sel)
270+ assert.Contains(t, lightCSS, sel+" {", "light CSS must have selector %s", sel)
271+ assert.Contains(t, customCSS, sel+" {", "custom CSS must have selector %s", sel)
272+ }
273+
274+ // Palette-specific values must differ.
275+ assert.NotEqual(t, darkCSS, lightCSS, "dark and light GenerateCSSFull output must differ")
276+ assert.NotEqual(t, darkCSS, customCSS, "dark and custom GenerateCSSFull output must differ")
277+ assert.NotEqual(t, lightCSS, customCSS, "light and custom GenerateCSSFull output must differ")
278+
279+ // Sidebar CSS should always use the accent variable, while the :root block
280+ // carries the palette-specific value that changes on live reload.
281+ assert.Contains(t, darkCSS, "alpha(var(--accent), 0.18)")
282+ assert.Contains(t, lightCSS, "alpha(var(--accent), 0.18)")
283+ assert.Contains(t, customCSS, "alpha(var(--accent), 0.18)")
284+ assert.Contains(t, darkCSS, "\n --accent: "+darkPalette.Accent+";\n")
285+ assert.Contains(t, lightCSS, "\n --accent: "+lightPalette.Accent+";\n")
286+ assert.Contains(t, customCSS, "\n --accent: "+customPalette.Accent+";\n")
287+
288+ // All three outputs must be syntactically valid: every '{' has a matching '}'.
289+ for name, css := range map[string]string{
290+ "dark": darkCSS,
291+ "light": lightCSS,
292+ "custom": customCSS,
293+ } {
294+ assert.Equal(t, strings.Count(css, "{"), strings.Count(css, "}"),
295+ "%s CSS braces must be balanced", name)
296+ }
297+}
298+
299+// TestGenerateHistorySidebarCSS_LiveReloadPaletteSwitch verifies that
300+// switching palettes at GenerateCSSFull level updates the history sidebar
301+// accent values. This simulates what happens when the theme system applies
302+// a new palette at runtime: the full CSS (including sidebar) is regenerated.
303+func TestGenerateHistorySidebarCSS_LiveReloadPaletteSwitch(t *testing.T) {
304+ // Start with dark palette.
305+ current := DefaultDarkPalette()
306+ cssBefore := GenerateCSSFull(current, 1.0, DefaultFontConfig(), DefaultModeColors())
307+
308+ // Verify the sidebar section uses the shared accent variable.
309+ assert.Contains(t, cssBefore, "alpha(var(--accent), 0.18)",
310+ "initial palette should render sidebar CSS through the accent variable")
311+ assert.Contains(t, cssBefore, "\n --accent: "+current.Accent+";\n")
312+
313+ // Switch to light palette (simulating runtime theme change).
314+ current = DefaultLightPalette()
315+ cssAfter := GenerateCSSFull(current, 1.0, DefaultFontConfig(), DefaultModeColors())
316+
317+ assert.Contains(t, cssAfter, "alpha(var(--accent), 0.18)",
318+ "updated palette should still use the shared accent variable")
319+ assert.Contains(t, cssAfter, "\n --accent: "+current.Accent+";\n")
320+
321+ // The old palette variable definition must not remain after switching.
322+ darkAccent := DefaultDarkPalette().Accent
323+ if current.Accent != darkAccent {
324+ assert.NotContains(t, cssAfter, "\n --accent: "+darkAccent+";\n")
325+ }
326+
327+ // History sidebar selectors must be present in both.
328+ assert.Contains(t, cssBefore, ".history-sidebar-row:selected {")
329+ assert.Contains(t, cssAfter, ".history-sidebar-row:selected {")
330+
331+ // The before and after CSS must be different (palette changed).
332+ assert.NotEqual(t, cssBefore, cssAfter, "CSS must change when palette switches")
333+}
M internal/ui/window/main_window.go +111 -33
@@ -18,12 +18,24 @@ const (
18 )18 )
19 19
20 // MainWindow represents the main browser window.20 // MainWindow represents the main browser window.
21+//
22+// Layout hierarchy:
23+//
24+// window
25+// rootBox (vertical)
26+// contentOverlay
27+// contentAreaHBox (horizontal)
28+// mainContentBox (vertical, expand) ← workspace tab content
29+// sidebarBox (vertical, fixed width) ← optional sidebar
30+// tabBar (overlay, not measured)
21 type MainWindow struct {31 type MainWindow struct {
22 window *gtk.ApplicationWindow32 window *gtk.ApplicationWindow
23 rootBox *gtk.Box // Vertical: tab bar + content33 rootBox *gtk.Box // Vertical: tab bar + content
24 tabBar *component.TabBar34 tabBar *component.TabBar
25 contentOverlay *gtk.Overlay // Overlay for content + omnibox35 contentOverlay *gtk.Overlay // Overlay for content + omnibox
26- contentArea *gtk.Box // Container for workspace content36+ contentAreaBox *gtk.Box // Horizontal: main content + sidebar
37+ mainContentBox *gtk.Box // Vertical container for workspace content
38+ sidebarBox *gtk.Box // Vertical container for sidebar (hidden by default)
27 currentContent *gtk.Widget // Track current content for removal on tab switch39 currentContent *gtk.Widget // Track current content for removal on tab switch
28 40
29 tabBarPosition string // "top" or "bottom"41 tabBarPosition string // "top" or "bottom"
@@ -49,10 +61,26 @@ func New(ctx context.Context, app *gtk.Application, tabBarPosition string) (*Mai
49 mw.window.SetTitle(&title)61 mw.window.SetTitle(&title)
50 mw.window.SetDefaultSize(defaultWidth, defaultHeight)62 mw.window.SetDefaultSize(defaultWidth, defaultHeight)
51 63
64+ if err := mw.initLayoutWidgets(); err != nil {
65+ return nil, err
66+ }
67+
68+ mw.contentAreaBox.Append(&mw.mainContentBox.Widget)
69+ mw.contentAreaBox.Append(&mw.sidebarBox.Widget)
70+ mw.contentOverlay.SetChild(&mw.contentAreaBox.Widget)
71+
72+ mw.assembleLayout()
73+
74+ mw.window.SetChild(&mw.rootBox.Widget)
75+
76+ return mw, nil
77+}
78+
79+func (mw *MainWindow) initLayoutWidgets() error {
52 mw.rootBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)80 mw.rootBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
53 if mw.rootBox == nil {81 if mw.rootBox == nil {
54 mw.window.Unref()82 mw.window.Unref()
55- return nil, ErrWidgetCreationFailed("rootBox")83+ return ErrWidgetCreationFailed("rootBox")
56 }84 }
57 mw.rootBox.SetHexpand(true)85 mw.rootBox.SetHexpand(true)
58 mw.rootBox.SetVexpand(true)86 mw.rootBox.SetVexpand(true)
@@ -62,40 +90,77 @@ func New(ctx context.Context, app *gtk.Application, tabBarPosition string) (*Mai
62 if mw.tabBar == nil {90 if mw.tabBar == nil {
63 mw.rootBox.Unref()91 mw.rootBox.Unref()
64 mw.window.Unref()92 mw.window.Unref()
65- return nil, ErrWidgetCreationFailed("tabBar")93+ return ErrWidgetCreationFailed("tabBar")
94+ }
95+
96+ if err := mw.initContentWidgets(); err != nil {
97+ return err
66 }98 }
99+ return nil
100+}
67 101
102+func (mw *MainWindow) initContentWidgets() error {
68 mw.contentOverlay = gtk.NewOverlay()103 mw.contentOverlay = gtk.NewOverlay()
69 if mw.contentOverlay == nil {104 if mw.contentOverlay == nil {
70 mw.tabBar.Destroy()105 mw.tabBar.Destroy()
71 mw.rootBox.Unref()106 mw.rootBox.Unref()
72 mw.window.Unref()107 mw.window.Unref()
73- return nil, ErrWidgetCreationFailed("contentOverlay")108+ return ErrWidgetCreationFailed("contentOverlay")
74 }109 }
75 mw.contentOverlay.SetHexpand(true)110 mw.contentOverlay.SetHexpand(true)
76 mw.contentOverlay.SetVexpand(true)111 mw.contentOverlay.SetVexpand(true)
77 mw.contentOverlay.SetVisible(true)112 mw.contentOverlay.SetVisible(true)
78 113
79- mw.contentArea = gtk.NewBox(gtk.OrientationVerticalValue, 0)114+ // Horizontal box to hold both main content and sidebar side by side.
80- if mw.contentArea == nil {115+ mw.contentAreaBox = gtk.NewBox(gtk.OrientationHorizontalValue, 0)
116+ if mw.contentAreaBox == nil {
81 mw.contentOverlay.Unref()117 mw.contentOverlay.Unref()
82 mw.tabBar.Destroy()118 mw.tabBar.Destroy()
83 mw.rootBox.Unref()119 mw.rootBox.Unref()
84 mw.window.Unref()120 mw.window.Unref()
85- return nil, ErrWidgetCreationFailed("contentArea")121+ return ErrWidgetCreationFailed("contentAreaBox")
86 }122 }
87- mw.contentArea.SetHexpand(true)123+ mw.contentAreaBox.SetHexpand(true)
88- mw.contentArea.SetVexpand(true)124+ mw.contentAreaBox.SetVexpand(true)
89- mw.contentArea.SetVisible(true)125+ mw.contentAreaBox.SetVisible(true)
90- mw.contentArea.AddCssClass("content-area")
91 126
92- mw.contentOverlay.SetChild(&mw.contentArea.Widget)127+ if err := mw.initContentBoxes(); err != nil {
93-128+ return err
94- mw.assembleLayout()129+ }
130+ return nil
131+}
95 132
96- mw.window.SetChild(&mw.rootBox.Widget)133+func (mw *MainWindow) initContentBoxes() error {
134+ // Main content box (vertical) for workspace content.
135+ mw.mainContentBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
136+ if mw.mainContentBox == nil {
137+ mw.contentAreaBox.Unref()
138+ mw.contentOverlay.Unref()
139+ mw.tabBar.Destroy()
140+ mw.rootBox.Unref()
141+ mw.window.Unref()
142+ return ErrWidgetCreationFailed("mainContentBox")
143+ }
144+ mw.mainContentBox.SetHexpand(true)
145+ mw.mainContentBox.SetVexpand(true)
146+ mw.mainContentBox.SetVisible(true)
147+
148+ // Sidebar box (hidden by default).
149+ mw.sidebarBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
150+ if mw.sidebarBox == nil {
151+ mw.mainContentBox.Unref()
152+ mw.contentAreaBox.Unref()
153+ mw.contentOverlay.Unref()
154+ mw.tabBar.Destroy()
155+ mw.rootBox.Unref()
156+ mw.window.Unref()
157+ return ErrWidgetCreationFailed("sidebarBox")
158+ }
159+ mw.sidebarBox.SetHexpand(false)
160+ mw.sidebarBox.SetVexpand(true)
161+ mw.sidebarBox.SetVisible(false)
97 162
98- return mw, nil163+ return nil
99 }164 }
100 165
101 func (mw *MainWindow) assembleLayout() {166 func (mw *MainWindow) assembleLayout() {
@@ -147,21 +212,26 @@ func (mw *MainWindow) TabBar() *component.TabBar {
147 return mw.tabBar212 return mw.tabBar
148 }213 }
149 214
150-// ContentArea returns the main content container widget.215+// ContentArea returns the main content container widget (the vertical
216+// box where workspace content is placed).
151 func (mw *MainWindow) ContentArea() *gtk.Box {217 func (mw *MainWindow) ContentArea() *gtk.Box {
152- return mw.contentArea218+ return mw.mainContentBox
153 }219 }
154 220
155-// SetContent replaces the current content widget with the given widget.221+// SetContent replaces the current content widget in the main content area
222+// (the vertical box that holds workspace tab content).
156 func (mw *MainWindow) SetContent(widget *gtk.Widget) {223 func (mw *MainWindow) SetContent(widget *gtk.Widget) {
224+ if mw == nil || mw.mainContentBox == nil {
225+ return
226+ }
157 if mw.currentContent != nil {227 if mw.currentContent != nil {
158- mw.contentArea.Remove(mw.currentContent)228+ mw.mainContentBox.Remove(mw.currentContent)
159 mw.currentContent = nil229 mw.currentContent = nil
160 }230 }
161 231
162 if widget != nil {232 if widget != nil {
163 widget.SetVisible(true)233 widget.SetVisible(true)
164- mw.contentArea.Append(widget)234+ mw.mainContentBox.Append(widget)
165 mw.currentContent = widget235 mw.currentContent = widget
166 }236 }
167 }237 }
@@ -201,30 +271,30 @@ func (mw *MainWindow) ContentOverlay() *gtk.Overlay {
201 }271 }
202 272
203 // SetTabBarContentInsetVisible adds or removes the tab bar inset CSS class273 // SetTabBarContentInsetVisible adds or removes the tab bar inset CSS class
204-// on the content area. Avoids duplicate add/remove if already in the desired state.274+// on the main content area. Avoids duplicate add/remove if already in the desired state.
205 func (mw *MainWindow) SetTabBarContentInsetVisible(visible bool) {275 func (mw *MainWindow) SetTabBarContentInsetVisible(visible bool) {
206- if mw.contentArea == nil {276+ if mw.mainContentBox == nil {
207 return277 return
208 }278 }
209 class := mw.tabBarContentInsetClass()279 class := mw.tabBarContentInsetClass()
210 if visible {280 if visible {
211- if !mw.contentArea.HasCssClass(class) {281+ if !mw.mainContentBox.HasCssClass(class) {
212- mw.contentArea.AddCssClass(class)282+ mw.mainContentBox.AddCssClass(class)
213 }283 }
214 } else {284 } else {
215- if mw.contentArea.HasCssClass(class) {285+ if mw.mainContentBox.HasCssClass(class) {
216- mw.contentArea.RemoveCssClass(class)286+ mw.mainContentBox.RemoveCssClass(class)
217 }287 }
218 }288 }
219 }289 }
220 290
221 // HasTabBarContentInset returns whether the tab bar inset CSS class is currently291 // HasTabBarContentInset returns whether the tab bar inset CSS class is currently
222-// applied to the content area.292+// applied to the main content area.
223 func (mw *MainWindow) HasTabBarContentInset() bool {293 func (mw *MainWindow) HasTabBarContentInset() bool {
224- if mw.contentArea == nil {294+ if mw.mainContentBox == nil {
225 return false295 return false
226 }296 }
227- return mw.contentArea.HasCssClass(mw.tabBarContentInsetClass())297+ return mw.mainContentBox.HasCssClass(mw.tabBarContentInsetClass())
228 }298 }
229 299
230 func (mw *MainWindow) tabBarContentInsetClass() string {300 func (mw *MainWindow) tabBarContentInsetClass() string {
@@ -247,9 +317,17 @@ func (mw *MainWindow) Destroy() {
247 mw.tabBar.Destroy()317 mw.tabBar.Destroy()
248 mw.tabBar = nil318 mw.tabBar = nil
249 }319 }
250- if mw.contentArea != nil {320+ if mw.sidebarBox != nil {
251- mw.contentArea.Unref()321+ mw.sidebarBox.Unref()
252- mw.contentArea = nil322+ mw.sidebarBox = nil
323+ }
324+ if mw.mainContentBox != nil {
325+ mw.mainContentBox.Unref()
326+ mw.mainContentBox = nil
327+ }
328+ if mw.contentAreaBox != nil {
329+ mw.contentAreaBox.Unref()
330+ mw.contentAreaBox = nil
253 }331 }
254 if mw.rootBox != nil {332 if mw.rootBox != nil {
255 mw.rootBox.Unref()333 mw.rootBox.Unref()
A internal/ui/window/main_window_sidebar.go +93 -0
@@ -0,0 +1,93 @@
1+package window
2+
3+import (
4+ "github.com/bnema/puregotk/v4/gtk"
5+)
6+
7+// SidebarWidthConfig defines the initial/recommended width range for the sidebar.
8+type SidebarWidthConfig struct {
9+ // WidthPx is the preferred sidebar width.
10+ WidthPx int
11+ // MinPx is the minimum clamped width (default 280).
12+ MinPx int
13+ // MaxPx is the maximum clamping bound (default 380).
14+ MaxPx int
15+}
16+
17+// SidebarDefaultWidth returns a sensible default width configuration:
18+// preferred 320px, clamped to [280, 380].
19+func SidebarDefaultWidth() SidebarWidthConfig {
20+ return SidebarWidthConfig{
21+ WidthPx: 320,
22+ MinPx: 280,
23+ MaxPx: 380,
24+ }
25+}
26+
27+// SetSidebarWidth sets the sidebar box width to widthPx, clamped to the
28+// config's [MinPx, MaxPx] bounds. Using the zero-value SidebarWidthConfig{}
29+// sets sensible defaults (320px clamped to [280, 380]).
30+func (mw *MainWindow) SetSidebarWidth(cfg SidebarWidthConfig) {
31+ if mw.sidebarBox == nil {
32+ return
33+ }
34+ defaults := SidebarDefaultWidth()
35+ if cfg.MinPx == 0 {
36+ cfg.MinPx = defaults.MinPx
37+ }
38+ if cfg.MaxPx == 0 {
39+ cfg.MaxPx = defaults.MaxPx
40+ }
41+ if cfg.WidthPx == 0 {
42+ cfg.WidthPx = defaults.WidthPx
43+ }
44+ if cfg.MinPx > cfg.MaxPx {
45+ mw.logger.Warn().Int("min_px", cfg.MinPx).Int("max_px", cfg.MaxPx).Msg("invalid sidebar width bounds; swapping")
46+ cfg.MinPx, cfg.MaxPx = cfg.MaxPx, cfg.MinPx
47+ }
48+ clamped := cfg.WidthPx
49+ if clamped < cfg.MinPx {
50+ clamped = cfg.MinPx
51+ }
52+ if clamped > cfg.MaxPx {
53+ clamped = cfg.MaxPx
54+ }
55+ mw.sidebarBox.SetSizeRequest(clamped, -1)
56+ mw.logger.Debug().Int("sidebar_width", clamped).Msg("sidebar width set")
57+}
58+
59+// SetSidebarVisible shows or hides the sidebar pane.
60+func (mw *MainWindow) SetSidebarVisible(visible bool) {
61+ if mw.sidebarBox == nil {
62+ return
63+ }
64+ mw.sidebarBox.SetVisible(visible)
65+ mw.logger.Debug().Bool("sidebar_visible", visible).Msg("sidebar visibility changed")
66+}
67+
68+// IsSidebarVisible returns whether the sidebar pane is currently visible.
69+func (mw *MainWindow) IsSidebarVisible() bool {
70+ if mw.sidebarBox == nil {
71+ return false
72+ }
73+ return mw.sidebarBox.GetVisible()
74+}
75+
76+// SetSidebarWidget replaces the current sidebar content widget.
77+func (mw *MainWindow) SetSidebarWidget(widget *gtk.Widget) {
78+ if mw.sidebarBox == nil {
79+ return
80+ }
81+ // Remove existing children
82+ for {
83+ child := mw.sidebarBox.GetFirstChild()
84+ if child == nil {
85+ break
86+ }
87+ mw.sidebarBox.Remove(child)
88+ }
89+ if widget != nil {
90+ widget.SetVisible(true)
91+ mw.sidebarBox.Append(widget)
92+ }
93+}

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