detached 1ed6020

refactor(history): split sidebar responsibilities

commit
1ed6020e2c381c01091d9e2d9b545e02afee4898
author
brice · · 2026-06-13 07:44 UTC
parent
d0951c4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +2653 -2518
M internal/ui/browser_window.go +0 -135
@@ -269,141 +269,6 @@ func (bw *browserWindow) ensureTabs() {
269269 }
270270 }
271271
272-// initHistorySidebar creates and mounts the history sidebar into the
273-// browser window's sidebar container. The sidebar is hidden by default.
274-func (bw *browserWindow) initHistorySidebar(ctx context.Context, a *App) {
275- if bw == nil || a == nil || bw.mainWindow == nil || a.deps == nil || a.deps.HistoryUC == nil {
276- return
277- }
278- log := logging.FromContext(ctx)
279-
280- cfg := a.buildHistorySidebarConfig(ctx, bw)
281-
282- sidebar := component.NewHistorySidebar(ctx, cfg)
283- if sidebar == nil {
284- log.Warn().Msg("failed to create history sidebar")
285- return
286- }
287-
288- bw.historySidebar = sidebar
289- bw.sidebarVisible = false
290-
291- // Mount into the main window's sidebar box
292- bw.mainWindow.SetSidebarWidget(sidebar.Widget())
293-
294- // Apply sidebar width from config, falling back to the default 320px.
295- // The width is clamped to [280, 380] by SetSidebarWidth internally.
296- bw.applySidebarWidthConfig(a)
297-
298- log.Debug().Msg("history sidebar initialized")
299-}
300-
301-// buildHistorySidebarConfig constructs the HistorySidebarConfig for the given
302-// browser window. Extracted from initHistorySidebar for testability.
303-func (a *App) buildHistorySidebarConfig(ctx context.Context, bw *browserWindow) component.HistorySidebarConfig {
304- var historyUC port.HistorySidebarHistory
305- if a.deps != nil {
306- historyUC = a.deps.HistoryUC
307- }
308-
309- return component.HistorySidebarConfig{
310- HistoryUC: historyUC,
311- OnNavigate: func(navCtx context.Context, url string) error {
312- return a.navigateHistorySidebarSelection(navCtx, bw, url)
313- },
314- OnNavigateKeepOpen: func(navCtx context.Context, url string) error {
315- return a.navigateHistorySidebarSelection(navCtx, bw, url)
316- },
317- OnOpenInNewPane: func(splitCtx context.Context, url string) error {
318- if a.wsCoord == nil || !a.hasBrowserWindow(bw) {
319- return nil
320- }
321- a.activateBrowserWindow(bw)
322- return a.wsCoord.SplitWithURL(splitCtx, usecase.SplitRight, url)
323- },
324- OnClose: func() {
325- a.hideAndRestoreFocusForBrowserWindow(bw)
326- },
327- }
328-}
329-
330-func (a *App) navigateHistorySidebarSelection(ctx context.Context, bw *browserWindow, url string) error {
331- if a == nil || bw == nil || !a.hasBrowserWindow(bw) {
332- return nil
333- }
334- return a.navigateFromBrowserWindow(ctx, bw, url)
335-}
336-
337-// toggleHistorySidebar toggles sidebar visibility. An optional width config
338-// can be provided and is applied when showing the sidebar.
339-func (bw *browserWindow) toggleHistorySidebar(widthCfg ...window.SidebarWidthConfig) {
340- if bw == nil || bw.historySidebar == nil {
341- return
342- }
343-
344- if bw.sidebarVisible {
345- bw.hideHistorySidebar()
346- } else {
347- bw.showHistorySidebar(widthCfg...)
348- }
349-}
350-
351-// showHistorySidebar makes the sidebar visible and grabs focus for the search
352-// entry. An optional width config can be provided to override the default width.
353-func (bw *browserWindow) showHistorySidebar(widthCfg ...window.SidebarWidthConfig) {
354- if bw == nil || bw.historySidebar == nil || bw.mainWindow == nil {
355- return
356- }
357- // Apply width config if provided
358- if len(widthCfg) > 0 {
359- bw.mainWindow.SetSidebarWidth(widthCfg[0])
360- }
361- bw.historySidebar.Show()
362- bw.mainWindow.SetSidebarVisible(true)
363- bw.sidebarVisible = true
364-}
365-
366-// applySidebarWidthConfig extracts the config-backed sidebar width and
367-// applies it via the MainWindow.SetSidebarWidth path. It is called during
368-// initialization and can be reused if config is reloaded at runtime.
369-func historySidebarWidthConfig(widthPx int) window.SidebarWidthConfig {
370- cfg := window.SidebarDefaultWidth()
371- if widthPx > 0 {
372- cfg.WidthPx = widthPx
373- }
374- return cfg
375-}
376-
377-func (a *App) toggleHistorySidebarAction(ctx context.Context) error {
378- bw := a.lastFocusedBrowserWindow()
379- if bw == nil {
380- return fmt.Errorf("history sidebar unavailable: no focused browser window")
381- }
382- if bw.historySidebar == nil {
383- return fmt.Errorf("history sidebar unavailable: native sidebar not initialized")
384- }
385- bw.toggleHistorySidebar()
386- return nil
387-}
388-
389-func (bw *browserWindow) applySidebarWidthConfig(a *App) {
390- if bw == nil || bw.mainWindow == nil || a == nil || a.deps == nil || a.deps.Config == nil {
391- return
392- }
393- bw.mainWindow.SetSidebarWidth(historySidebarWidthConfig(a.deps.Config.SidebarWidth))
394-}
395-
396-// hideHistorySidebar hides the sidebar. Callers should also restore focus
397-// to the active content pane after calling this.
398-func (bw *browserWindow) hideHistorySidebar() {
399- if bw == nil || bw.historySidebar == nil || bw.mainWindow == nil {
400- return
401- }
402- bw.historySidebar.Hide()
403- bw.mainWindow.SetSidebarVisible(false)
404- bw.sidebarVisible = false
405-}
406-
407272 func (a *App) hasBrowserWindow(target *browserWindow) bool {
408273 if a == nil || target == nil || a.browserWindows == nil {
409274 return false
A internal/ui/browser_window_history_sidebar.go +151 -0
@@ -0,0 +1,151 @@
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(ctx, 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+ bw.sidebarVisible = false
32+
33+ // Mount into the main window's sidebar box
34+ bw.mainWindow.SetSidebarWidget(sidebar.Widget())
35+
36+ // Apply sidebar width from config, falling back to the default 320px.
37+ // The width is clamped to [280, 380] by SetSidebarWidth internally.
38+ bw.applySidebarWidthConfig(a)
39+
40+ log.Debug().Msg("history sidebar initialized")
41+}
42+
43+// buildHistorySidebarConfig constructs the HistorySidebarConfig for the given
44+// browser window. Extracted from initHistorySidebar for testability.
45+func (a *App) buildHistorySidebarConfig(ctx context.Context, bw *browserWindow) component.HistorySidebarConfig {
46+ var historyUC port.HistorySidebarHistory
47+ if a.deps != nil {
48+ historyUC = a.deps.HistoryUC
49+ }
50+
51+ return component.HistorySidebarConfig{
52+ HistoryUC: historyUC,
53+ OnNavigate: func(navCtx context.Context, url string) error {
54+ return a.navigateHistorySidebarSelection(navCtx, bw, url)
55+ },
56+ OnNavigateKeepOpen: func(navCtx context.Context, url string) error {
57+ return a.navigateHistorySidebarSelection(navCtx, bw, url)
58+ },
59+ OnOpenInNewPane: func(splitCtx context.Context, url string) error {
60+ if a.wsCoord == nil || !a.hasBrowserWindow(bw) {
61+ return nil
62+ }
63+ a.activateBrowserWindow(bw)
64+ return a.wsCoord.SplitWithURL(splitCtx, usecase.SplitRight, url)
65+ },
66+ OnClose: func() {
67+ a.hideAndRestoreFocusForBrowserWindow(bw)
68+ },
69+ }
70+}
71+
72+func (a *App) navigateHistorySidebarSelection(ctx context.Context, bw *browserWindow, url string) error {
73+ if a == nil || bw == nil || !a.hasBrowserWindow(bw) {
74+ return nil
75+ }
76+ return a.navigateFromBrowserWindow(ctx, bw, url)
77+}
78+
79+// toggleHistorySidebar toggles sidebar visibility. An optional width config
80+// can be provided and is applied when showing the sidebar.
81+func (bw *browserWindow) toggleHistorySidebar(widthCfg ...window.SidebarWidthConfig) {
82+ if bw == nil || bw.historySidebar == nil {
83+ return
84+ }
85+
86+ if bw.sidebarVisible {
87+ bw.hideHistorySidebar()
88+ } else {
89+ bw.showHistorySidebar(widthCfg...)
90+ }
91+}
92+
93+// showHistorySidebar makes the sidebar visible and grabs focus for the search
94+// entry. An optional width config can be provided to override the default width.
95+func (bw *browserWindow) showHistorySidebar(widthCfg ...window.SidebarWidthConfig) {
96+ if bw == nil || bw.historySidebar == nil || bw.mainWindow == nil {
97+ return
98+ }
99+ // Apply width config if provided
100+ if len(widthCfg) > 0 {
101+ bw.mainWindow.SetSidebarWidth(widthCfg[0])
102+ }
103+ bw.historySidebar.Show()
104+ bw.mainWindow.SetSidebarVisible(true)
105+ bw.sidebarVisible = true
106+}
107+
108+// hideHistorySidebar hides the sidebar. Callers should also restore focus
109+// to the active content pane after calling this.
110+func (bw *browserWindow) hideHistorySidebar() {
111+ if bw == nil || bw.historySidebar == nil || bw.mainWindow == nil {
112+ return
113+ }
114+ bw.historySidebar.Hide()
115+ bw.mainWindow.SetSidebarVisible(false)
116+ bw.sidebarVisible = false
117+}
118+
119+// historySidebarWidthConfig extracts the config-backed sidebar width and
120+// applies it via the MainWindow.SetSidebarWidth path. It is called during
121+// initialization and can be reused if config is reloaded at runtime.
122+func historySidebarWidthConfig(widthPx int) window.SidebarWidthConfig {
123+ cfg := window.SidebarDefaultWidth()
124+ if widthPx > 0 {
125+ cfg.WidthPx = widthPx
126+ }
127+ return cfg
128+}
129+
130+// applySidebarWidthConfig applies the config-backed sidebar width to the
131+// main window's sidebar.
132+func (bw *browserWindow) applySidebarWidthConfig(a *App) {
133+ if bw == nil || bw.mainWindow == nil || a == nil || a.deps == nil || a.deps.Config == nil {
134+ return
135+ }
136+ bw.mainWindow.SetSidebarWidth(historySidebarWidthConfig(a.deps.Config.SidebarWidth))
137+}
138+
139+// toggleHistorySidebarAction is the keyboard-action handler for toggling the
140+// history sidebar on the last focused browser window.
141+func (a *App) toggleHistorySidebarAction(ctx context.Context) error {
142+ bw := a.lastFocusedBrowserWindow()
143+ if bw == nil {
144+ return fmt.Errorf("history sidebar unavailable: no focused browser window")
145+ }
146+ if bw.historySidebar == nil {
147+ return fmt.Errorf("history sidebar unavailable: native sidebar not initialized")
148+ }
149+ bw.toggleHistorySidebar()
150+ return nil
151+}
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(ctx, 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{id: "window-1", tabs: bwTabs}
98+
99+ contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
100+ fakeWv := &fakeRecordingWebView{id: 1}
101+ contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), fakeWv)
102+ navCoord := coordinator.NewNavigationCoordinator(ctx, nil, contentCoord)
103+
104+ app := &App{
105+ tabs: entity.NewTabList(),
106+ browserWindows: map[string]*browserWindow{bw.id: bw},
107+ contentCoord: contentCoord,
108+ navCoord: navCoord,
109+ workspaceViews: map[entity.TabID]*component.WorkspaceView{
110+ tab.ID: {},
111+ },
112+ }
113+ app.tabs.Add(tab)
114+
115+ cfg := app.buildHistorySidebarConfig(ctx, bw)
116+ navigateURL := "https://keep-open.com"
117+ err := cfg.OnNavigateKeepOpen(ctx, navigateURL)
118+ require.NoError(t, err)
119+ assert.True(t, fakeWv.loadURICalled)
120+ assert.Equal(t, navigateURL, fakeWv.loadURILastURI, "Ctrl+Enter navigation should go to the URL")
121+}
122+
123+// TestHistorySidebar_OwnershipOnMultiWindowNavigation verifies that when
124+// multiple browser windows have history sidebars, navigation targets the
125+// correct owning window's active pane. This tests the stale-focus scenario
126+// where a different window is globally focused.
127+func TestHistorySidebar_OwnershipOnMultiWindowNavigation(t *testing.T) {
128+ ctx := t.Context()
129+
130+ // Two windows, each with their own tab and pane.
131+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
132+ tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
133+
134+ firstTabs := entity.NewTabList()
135+ firstTabs.Add(tab1)
136+ firstTabs.SetActive(tab1.ID)
137+ first := &browserWindow{id: "window-1", tabs: firstTabs}
138+
139+ secondTabs := entity.NewTabList()
140+ secondTabs.Add(tab2)
141+ secondTabs.SetActive(tab2.ID)
142+ second := &browserWindow{id: "window-2", tabs: secondTabs}
143+
144+ fakeWv1 := &fakeRecordingWebView{id: 1}
145+ fakeWv2 := &fakeRecordingWebView{id: 2}
146+
147+ contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
148+ contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), fakeWv1)
149+ contentCoord.RegisterPopupWebView(entity.PaneID("pane-2"), fakeWv2)
150+
151+ navCoord := coordinator.NewNavigationCoordinator(ctx, nil, contentCoord)
152+
153+ app := &App{
154+ tabs: entity.NewTabList(),
155+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
156+ lastFocusedWindowID: first.id, // STALE: first is globally focused
157+ contentCoord: contentCoord,
158+ navCoord: navCoord,
159+ workspaceViews: map[entity.TabID]*component.WorkspaceView{
160+ tab1.ID: {},
161+ tab2.ID: {},
162+ },
163+ }
164+ app.tabs.Add(tab1)
165+ app.tabs.Add(tab2)
166+
167+ // Navigation from the SECOND window should target pane-2 even though
168+ // the first window is globally focused (stale focus).
169+ err := app.navigateFromBrowserWindow(ctx, second, "https://second-window.com")
170+ require.NoError(t, err)
171+
172+ // Second window's webview must receive the navigation.
173+ assert.True(t, fakeWv2.loadURICalled, "second window webview should receive navigation")
174+ assert.Equal(t, "https://second-window.com", fakeWv2.loadURILastURI)
175+
176+ // First window's webview must NOT have been touched.
177+ assert.False(t, fakeWv1.loadURICalled, "first window webview should NOT receive navigation when second was targeted")
178+}
179+
180+// =============================================================================
181+// History sidebar toggle state tests
182+// =============================================================================
183+
184+// TestBrowserWindow_HistorySidebarToggle_NilIsNoOp verifies that when
185+// browserWindow.historySidebar is nil, toggleHistorySidebar is a safe
186+// no-op and sidebarVisible stays / remains false.
187+func TestBrowserWindow_HistorySidebarToggle_NilIsNoOp(t *testing.T) {
188+ t.Parallel()
189+
190+ bw := &browserWindow{id: "test-window", sidebarVisible: false}
191+ require.Nil(t, bw.historySidebar, "historySidebar must be nil for this test")
192+
193+ // Should not panic even though historySidebar is nil.
194+ bw.toggleHistorySidebar()
195+ assert.False(t, bw.sidebarVisible, "sidebarVisible must remain false when historySidebar is nil")
196+
197+ // Calling again also safe.
198+ bw.toggleHistorySidebar()
199+ assert.False(t, bw.sidebarVisible, "sidebarVisible must remain false on second toggle")
200+}
201+
202+// TestBrowserWindow_HistorySidebarToggle_FlipsSidebarVisible verifies that
203+// toggleHistorySidebar correctly flips sidebarVisible when the sidebar
204+// has been set. Uses a zero-value HistorySidebar (all nil-checked methods
205+// are safe to call).
206+func TestBrowserWindow_HistorySidebarToggle_FlipsSidebarVisible(t *testing.T) {
207+ t.Parallel()
208+
209+ bw := &browserWindow{
210+ id: "test-window",
211+ mainWindow: &window.MainWindow{},
212+ historySidebar: &component.HistorySidebar{},
213+ sidebarVisible: false,
214+ }
215+
216+ bw.toggleHistorySidebar()
217+ assert.True(t, bw.sidebarVisible, "sidebarVisible must be true after first toggle")
218+
219+ bw.toggleHistorySidebar()
220+ assert.False(t, bw.sidebarVisible, "sidebarVisible must be false after second toggle")
221+
222+ bw.toggleHistorySidebar()
223+ assert.True(t, bw.sidebarVisible, "sidebarVisible must be true after third toggle")
224+}
225+
226+// TestBrowserWindow_HistorySidebarShowHide_TransitionsSidebarVisible
227+// verifies that showHistorySidebar and hideHistorySidebar independently
228+// set sidebarVisible to true and false respectively.
229+func TestBrowserWindow_HistorySidebarShowHide_TransitionsSidebarVisible(t *testing.T) {
230+ t.Parallel()
231+
232+ bw := &browserWindow{
233+ id: "test-window",
234+ mainWindow: &window.MainWindow{},
235+ historySidebar: &component.HistorySidebar{},
236+ sidebarVisible: false,
237+ }
238+
239+ // Show sets visible
240+ bw.showHistorySidebar()
241+ assert.True(t, bw.sidebarVisible, "sidebarVisible must be true after show")
242+
243+ // Hide clears visible
244+ bw.hideHistorySidebar()
245+ assert.False(t, bw.sidebarVisible, "sidebarVisible must be false after hide")
246+
247+ // Redundant hide is idempotent
248+ bw.hideHistorySidebar()
249+ assert.False(t, bw.sidebarVisible, "sidebarVisible must remain false after redundant hide")
250+
251+ // Show again
252+ bw.showHistorySidebar()
253+ assert.True(t, bw.sidebarVisible, "sidebarVisible must be true after second show")
254+}
255+
256+// TestBrowserWindow_HistorySidebarShowHide_NilSidebarIsNoOp verifies that
257+// show/hide do not panic when historySidebar is nil.
258+func TestBrowserWindow_HistorySidebarShowHide_NilSidebarIsNoOp(t *testing.T) {
259+ t.Parallel()
260+
261+ bw := &browserWindow{id: "test-window"}
262+ require.Nil(t, bw.historySidebar)
263+
264+ // Should not panic even though historySidebar is nil.
265+ bw.showHistorySidebar()
266+ assert.False(t, bw.sidebarVisible, "sidebarVisible must remain false when nil")
267+
268+ bw.hideHistorySidebar()
269+ assert.False(t, bw.sidebarVisible, "sidebarVisible must remain false when nil")
270+}
271+
272+// =============================================================================
273+// App wiring: toggle handler uses lastFocusedBrowserWindow
274+// =============================================================================
275+
276+// TestApp_HistorySidebarToggleHandlerUsesLastFocusedWindow verifies that
277+// the toggle handler wired in App.wireKeyboardActions picks the
278+// lastFocusedBrowserWindow and calls toggleHistorySidebar on it.
279+func TestApp_HistorySidebarToggleHandlerUsesLastFocusedWindow(t *testing.T) {
280+ // Two windows, only the focused one has a history sidebar.
281+ focusedBW := &browserWindow{
282+ id: "focused",
283+ mainWindow: &window.MainWindow{},
284+ historySidebar: &component.HistorySidebar{},
285+ sidebarVisible: false,
286+ }
287+ otherBW := &browserWindow{
288+ id: "other",
289+ mainWindow: &window.MainWindow{},
290+ }
291+
292+ app := &App{
293+ browserWindows: map[string]*browserWindow{
294+ focusedBW.id: focusedBW,
295+ otherBW.id: otherBW,
296+ },
297+ lastFocusedWindowID: focusedBW.id,
298+ }
299+
300+ // Simulate the toggle handler that wireKeyboardActions registers:
301+ // a.kbDispatcher.SetOnToggleHistorySidebar(func(ctx context.Context) error {
302+ // bw := a.lastFocusedBrowserWindow()
303+ // ...
304+ // })
305+ bw := app.lastFocusedBrowserWindow()
306+ require.NotNil(t, bw, "lastFocusedBrowserWindow must not be nil")
307+ require.Equal(t, focusedBW.id, bw.id, "must return the focused window")
308+ require.NotNil(t, bw.historySidebar, "focused window must have a history sidebar")
309+
310+ // Toggle on the focused window.
311+ bw.toggleHistorySidebar()
312+ assert.True(t, bw.sidebarVisible, "sidebar on focused window must become visible")
313+
314+ // The other window must remain untouched.
315+ assert.False(t, otherBW.sidebarVisible, "other window sidebar must remain invisible")
316+
317+ // Toggle again: focused window sidebar hides.
318+ bw.toggleHistorySidebar()
319+ assert.False(t, bw.sidebarVisible, "sidebar on focused window must hide on second toggle")
320+}
321+
322+// TestApp_HistorySidebarToggleHandler_NilFocusedWindowIsNoOp verifies
323+// that the toggle handler is safe when lastFocusedBrowserWindow returns nil.
324+func TestApp_HistorySidebarToggleHandler_NilFocusedWindowIsNoOp(t *testing.T) {
325+ app := &App{
326+ browserWindows: make(map[string]*browserWindow),
327+ lastFocusedWindowID: "missing",
328+ }
329+
330+ // The handler should return early without error when bw is nil.
331+ bw := app.lastFocusedBrowserWindow()
332+ require.Nil(t, bw, "lastFocusedBrowserWindow should return nil for missing window")
333+}
334+
335+// =============================================================================
336+// History sidebar config callbacks: focus restoration on close
337+// =============================================================================
338+
339+// TestHistorySidebarConfig_OnCloseHidesSidebar verifies that the OnClose
340+// callback hides the sidebar for the owning browser window.
341+func TestHistorySidebarConfig_OnCloseHidesSidebar(t *testing.T) {
342+ ctx := t.Context()
343+ tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
344+ bwTabs := entity.NewTabList()
345+ bwTabs.Add(tab)
346+ bwTabs.SetActive(tab.ID)
347+
348+ bw := &browserWindow{
349+ id: "window-1",
350+ tabs: bwTabs,
351+ mainWindow: &window.MainWindow{},
352+ historySidebar: &component.HistorySidebar{},
353+ sidebarVisible: true,
354+ }
355+
356+ app := &App{
357+ tabs: entity.NewTabList(),
358+ browserWindows: map[string]*browserWindow{bw.id: bw},
359+ workspaceViews: map[entity.TabID]*component.WorkspaceView{tab.ID: {}},
360+ }
361+ app.tabs.Add(tab)
362+
363+ cfg := app.buildHistorySidebarConfig(ctx, bw)
364+ cfg.OnClose()
365+
366+ assert.False(t, bw.sidebarVisible, "sidebar must be hidden by OnClose")
367+}
368+
369+// =============================================================================
370+// Dispatcher-backed Ctrl+H integration test
371+// =============================================================================
372+
373+// TestApp_HistorySidebar_ToggleThroughKeyboardDispatcher wires the real toggle
374+// handler from App.wireKeyboardActions through the KeyboardDispatcher and
375+// dispatches ActionToggleHistorySystemView, asserting the focused browser
376+// window's sidebar visibility toggles.
377+func TestApp_HistorySidebar_ToggleThroughKeyboardDispatcher(t *testing.T) {
378+ ctx := t.Context()
379+
380+ focusedBW := &browserWindow{
381+ id: "focused",
382+ mainWindow: &window.MainWindow{},
383+ historySidebar: &component.HistorySidebar{},
384+ sidebarVisible: false,
385+ }
386+ otherBW := &browserWindow{
387+ id: "other",
388+ mainWindow: &window.MainWindow{},
389+ }
390+
391+ app := &App{
392+ browserWindows: map[string]*browserWindow{
393+ focusedBW.id: focusedBW,
394+ otherBW.id: otherBW,
395+ },
396+ lastFocusedWindowID: focusedBW.id,
397+ }
398+
399+ // Create a KeyboardDispatcher and wire the production toggle handler.
400+ kbDispatcher := dispatcher.NewKeyboardDispatcher(
401+ ctx,
402+ &coordinator.WorkspaceCoordinator{},
403+ &coordinator.NavigationCoordinator{},
404+ nil, nil,
405+ dispatcher.KeyboardActions{},
406+ func(context.Context) entity.PaneID { return "" },
407+ )
408+
409+ kbDispatcher.SetOnToggleHistorySidebar(app.toggleHistorySidebarAction)
410+
411+ // First dispatch: toggle ON.
412+ err := kbDispatcher.Dispatch(ctx, input.ActionToggleHistorySystemView)
413+ require.NoError(t, err)
414+ assert.True(t, focusedBW.sidebarVisible, "focused window sidebar must be visible after toggle")
415+ assert.False(t, otherBW.sidebarVisible, "other window sidebar must remain invisible")
416+
417+ // Second dispatch: toggle OFF.
418+ err = kbDispatcher.Dispatch(ctx, input.ActionToggleHistorySystemView)
419+ require.NoError(t, err)
420+ assert.False(t, focusedBW.sidebarVisible, "focused window sidebar must be hidden after second toggle")
421+}
422+
423+// TestApp_HistorySidebar_ToggleThroughDispatcher_UnavailableReturnsError verifies that
424+// when the focused window has no history sidebar, Ctrl+H returns a clean error.
425+func TestApp_HistorySidebar_ToggleThroughDispatcher_UnavailableReturnsError(t *testing.T) {
426+ ctx := t.Context()
427+
428+ bw := &browserWindow{
429+ id: "no-sidebar",
430+ mainWindow: &window.MainWindow{},
431+ // historySidebar is nil
432+ }
433+
434+ app := &App{
435+ browserWindows: map[string]*browserWindow{bw.id: bw},
436+ lastFocusedWindowID: bw.id,
437+ }
438+
439+ kbDispatcher := dispatcher.NewKeyboardDispatcher(
440+ ctx,
441+ &coordinator.WorkspaceCoordinator{},
442+ &coordinator.NavigationCoordinator{},
443+ nil, nil,
444+ dispatcher.KeyboardActions{},
445+ func(context.Context) entity.PaneID { return "" },
446+ )
447+
448+ kbDispatcher.SetOnToggleHistorySidebar(app.toggleHistorySidebarAction)
449+
450+ err := kbDispatcher.Dispatch(ctx, input.ActionToggleHistorySystemView)
451+ require.Error(t, err)
452+ assert.ErrorContains(t, err, "history sidebar unavailable")
453+ assert.False(t, bw.sidebarVisible, "sidebar must remain invisible when not wired")
454+}
455+
456+// TestApp_HistorySidebar_ToggleThroughDispatcher_NilFocusedReturnsError verifies
457+// that the toggle handler returns a clean error when there is no focused window.
458+func TestApp_HistorySidebar_ToggleThroughDispatcher_NilFocusedReturnsError(t *testing.T) {
459+ ctx := t.Context()
460+
461+ app := &App{
462+ browserWindows: make(map[string]*browserWindow),
463+ lastFocusedWindowID: "missing",
464+ }
465+
466+ kbDispatcher := dispatcher.NewKeyboardDispatcher(
467+ ctx,
468+ &coordinator.WorkspaceCoordinator{},
469+ &coordinator.NavigationCoordinator{},
470+ nil, nil,
471+ dispatcher.KeyboardActions{},
472+ func(context.Context) entity.PaneID { return "" },
473+ )
474+
475+ kbDispatcher.SetOnToggleHistorySidebar(app.toggleHistorySidebarAction)
476+
477+ err := kbDispatcher.Dispatch(ctx, input.ActionToggleHistorySystemView)
478+ require.Error(t, err)
479+ assert.ErrorContains(t, err, "history sidebar unavailable")
480+}
481+
482+// =============================================================================
483+// buildHistorySidebarConfig callback seam tests
484+// =============================================================================
485+
486+// TestApp_HistorySidebarConfig_NavigateCallback verifies that the OnNavigate
487+// callback from buildHistorySidebarConfig navigates the owning browser window's
488+// active pane to the given URL.
489+func TestApp_HistorySidebarConfig_NavigateCallbackNavigates(t *testing.T) {
490+ ctx := t.Context()
491+
492+ paneID := entity.PaneID("pane-1")
493+ tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(paneID))
494+ bwTabs := entity.NewTabList()
495+ bwTabs.Add(tab)
496+ bwTabs.SetActive(tab.ID)
497+
498+ bw := &browserWindow{
499+ id: "window-1",
500+ tabs: bwTabs,
501+ mainWindow: &window.MainWindow{},
502+ historySidebar: &component.HistorySidebar{},
503+ sidebarVisible: true,
504+ }
505+
506+ fakeWv := &fakeRecordingWebView{id: 1}
507+ contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
508+ contentCoord.RegisterPopupWebView(paneID, fakeWv)
509+ navCoord := coordinator.NewNavigationCoordinator(ctx, nil, contentCoord)
510+
511+ app := &App{
512+ tabs: entity.NewTabList(),
513+ browserWindows: map[string]*browserWindow{bw.id: bw},
514+ contentCoord: contentCoord,
515+ navCoord: navCoord,
516+ workspaceViews: map[entity.TabID]*component.WorkspaceView{
517+ tab.ID: {},
518+ },
519+ }
520+ app.tabs.Add(tab)
521+
522+ // Build the config using the extracted seam.
523+ cfg := app.buildHistorySidebarConfig(ctx, bw)
524+ require.NotNil(t, cfg.OnNavigate, "OnNavigate callback must be non-nil")
525+
526+ // Invoke the OnNavigate callback.
527+ navigateURL := "https://navigated.com"
528+ err := cfg.OnNavigate(ctx, navigateURL)
529+ require.NoError(t, err)
530+
531+ // Verify the navigation reached the correct webview.
532+ assert.True(t, fakeWv.loadURICalled, "webview must receive navigation")
533+ assert.Equal(t, navigateURL, fakeWv.loadURILastURI)
534+ assert.True(t, bw.sidebarVisible, "default history navigation should keep the sidebar open")
535+}
536+
537+func TestApp_NavigateHistorySidebarSelection_KeepsSidebarVisible(t *testing.T) {
538+ ctx := t.Context()
539+
540+ paneID := entity.PaneID("pane-1")
541+ tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(paneID))
542+ bwTabs := entity.NewTabList()
543+ bwTabs.Add(tab)
544+ bwTabs.SetActive(tab.ID)
545+
546+ bw := &browserWindow{
547+ id: "window-1",
548+ tabs: bwTabs,
549+ mainWindow: &window.MainWindow{},
550+ historySidebar: &component.HistorySidebar{},
551+ sidebarVisible: true,
552+ }
553+
554+ fakeWv := &fakeRecordingWebView{id: 1}
555+ contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
556+ contentCoord.RegisterPopupWebView(paneID, fakeWv)
557+ navCoord := coordinator.NewNavigationCoordinator(ctx, nil, contentCoord)
558+
559+ app := &App{
560+ tabs: entity.NewTabList(),
561+ browserWindows: map[string]*browserWindow{bw.id: bw},
562+ contentCoord: contentCoord,
563+ navCoord: navCoord,
564+ workspaceViews: map[entity.TabID]*component.WorkspaceView{
565+ tab.ID: {},
566+ },
567+ }
568+ app.tabs.Add(tab)
569+
570+ err := app.navigateHistorySidebarSelection(ctx, bw, "https://open.com")
571+ require.NoError(t, err)
572+ assert.True(t, fakeWv.loadURICalled)
573+ assert.Equal(t, "https://open.com", fakeWv.loadURILastURI)
574+ assert.True(t, bw.sidebarVisible, "history selection navigation should not hide the sidebar")
575+}
576+
577+// TestApp_HistorySidebarConfig_NavigateCallbackOwnership verifies that
578+// OnNavigate targets the callback's owning window, not the globally focused
579+// window, when they differ.
580+func TestApp_HistorySidebarConfig_NavigateCallbackOwnership(t *testing.T) {
581+ ctx := t.Context()
582+
583+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
584+ tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
585+
586+ firstTabs := entity.NewTabList()
587+ firstTabs.Add(tab1)
588+ firstTabs.SetActive(tab1.ID)
589+ first := &browserWindow{id: "window-1", tabs: firstTabs}
590+
591+ secondTabs := entity.NewTabList()
592+ secondTabs.Add(tab2)
593+ secondTabs.SetActive(tab2.ID)
594+ second := &browserWindow{id: "window-2", tabs: secondTabs}
595+
596+ fakeWv1 := &fakeRecordingWebView{id: 1}
597+ fakeWv2 := &fakeRecordingWebView{id: 2}
598+
599+ contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
600+ contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), fakeWv1)
601+ contentCoord.RegisterPopupWebView(entity.PaneID("pane-2"), fakeWv2)
602+ navCoord := coordinator.NewNavigationCoordinator(ctx, nil, contentCoord)
603+
604+ app := &App{
605+ tabs: entity.NewTabList(),
606+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
607+ lastFocusedWindowID: first.id, // STALE: first is globally focused
608+ contentCoord: contentCoord,
609+ navCoord: navCoord,
610+ workspaceViews: map[entity.TabID]*component.WorkspaceView{
611+ tab1.ID: {},
612+ tab2.ID: {},
613+ },
614+ }
615+ app.tabs.Add(tab1)
616+ app.tabs.Add(tab2)
617+
618+ // Build config for the SECOND window, even though first is globally focused.
619+ cfg := app.buildHistorySidebarConfig(ctx, second)
620+
621+ // Invoke OnNavigate — should navigate through second window's pane-2.
622+ err := cfg.OnNavigate(ctx, "https://ownership.com")
623+ require.NoError(t, err)
624+
625+ // Second window's webview must receive navigation.
626+ assert.True(t, fakeWv2.loadURICalled, "second window webview must receive navigation")
627+ assert.Equal(t, "https://ownership.com", fakeWv2.loadURILastURI)
628+
629+ // First window's webview must NOT be touched.
630+ assert.False(t, fakeWv1.loadURICalled, "first window webview must not receive navigation")
631+}
632+
633+// TestApp_HistorySidebarConfig_KeepOpenCallback verifies that
634+// OnNavigateKeepOpen navigates the owning window without hiding the sidebar.
635+func TestApp_HistorySidebarConfig_KeepOpenCallback(t *testing.T) {
636+ ctx := t.Context()
637+
638+ paneID := entity.PaneID("pane-1")
639+ tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(paneID))
640+ bwTabs := entity.NewTabList()
641+ bwTabs.Add(tab)
642+ bwTabs.SetActive(tab.ID)
643+
644+ bw := &browserWindow{
645+ id: "window-1",
646+ tabs: bwTabs,
647+ mainWindow: &window.MainWindow{},
648+ historySidebar: &component.HistorySidebar{},
649+ sidebarVisible: true,
650+ }
651+
652+ fakeWv := &fakeRecordingWebView{id: 1}
653+ contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
654+ contentCoord.RegisterPopupWebView(paneID, fakeWv)
655+ navCoord := coordinator.NewNavigationCoordinator(ctx, nil, contentCoord)
656+
657+ app := &App{
658+ tabs: entity.NewTabList(),
659+ browserWindows: map[string]*browserWindow{bw.id: bw},
660+ contentCoord: contentCoord,
661+ navCoord: navCoord,
662+ workspaceViews: map[entity.TabID]*component.WorkspaceView{
663+ tab.ID: {},
664+ },
665+ }
666+ app.tabs.Add(tab)
667+
668+ cfg := app.buildHistorySidebarConfig(ctx, bw)
669+
670+ // OnNavigateKeepOpen navigates but does NOT close the sidebar.
671+ navigateURL := "https://keep-open.com"
672+ err := cfg.OnNavigateKeepOpen(ctx, navigateURL)
673+ require.NoError(t, err)
674+
675+ assert.True(t, fakeWv.loadURICalled, "webview must receive navigation")
676+ assert.Equal(t, navigateURL, fakeWv.loadURILastURI)
677+
678+ // Sidebar must remain visible (keep-open contract).
679+ assert.True(t, bw.sidebarVisible, "sidebar must stay visible after keep-open navigation")
680+}
681+
682+// TestApp_HistorySidebarConfig_OpenInNewPaneCallback verifies that
683+// OnOpenInNewPane activates the owning browser window and creates a split
684+// with the target URL.
685+func TestApp_HistorySidebarConfig_OpenInNewPaneCallback(t *testing.T) {
686+ ctx := t.Context()
687+
688+ paneID := entity.PaneID("pane-1")
689+ tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(paneID))
690+ bwTabs := entity.NewTabList()
691+ bwTabs.Add(tab)
692+ bwTabs.SetActive(tab.ID)
693+
694+ ws := entity.NewWorkspace("ws-1", entity.NewPane(paneID))
695+ ws.ActivePaneID = paneID
696+ tab.Workspace = ws
697+
698+ bw := &browserWindow{id: "window-1", tabs: bwTabs}
699+
700+ panesUC := usecase.NewManagePanesUseCase(func() string { return "pane-2" })
701+ wsCoord := coordinator.NewWorkspaceCoordinator(ctx, coordinator.WorkspaceCoordinatorConfig{
702+ PanesUC: panesUC,
703+ GetActiveWS: func() (*entity.Workspace, *component.WorkspaceView) {
704+ return ws, nil
705+ },
706+ })
707+
708+ app := &App{
709+ tabs: entity.NewTabList(),
710+ browserWindows: map[string]*browserWindow{bw.id: bw},
711+ wsCoord: wsCoord,
712+ workspaceViews: map[entity.TabID]*component.WorkspaceView{
713+ tab.ID: {},
714+ },
715+ }
716+ app.tabs.Add(tab)
717+
718+ cfg := app.buildHistorySidebarConfig(ctx, bw)
719+
720+ // OnOpenInNewPane should activate the owning window and split with URL.
721+ splitURL := "https://shift-enter.com"
722+ err := cfg.OnOpenInNewPane(ctx, splitURL)
723+ require.NoError(t, err)
724+
725+ // After split, workspace should have 2 panes.
726+ require.Equal(t, 2, ws.PaneCount(), "workspace should have 2 panes after split")
727+
728+ // The new pane should have the split URL.
729+ allPanes := ws.AllPanes()
730+ var newPane *entity.Pane
731+ for _, p := range allPanes {
732+ if p != nil && p.ID != paneID {
733+ newPane = p
734+ break
735+ }
736+ }
737+ require.NotNil(t, newPane, "new pane must exist after split")
738+ assert.Equal(t, splitURL, newPane.URI, "new pane must have the split URL")
739+}
740+
741+// TestApp_HistorySidebarConfig_CloseCallback verifies that OnClose hides the
742+// sidebar for the owning browser window and restores focus to the active pane.
743+func TestApp_HistorySidebarConfig_CloseCallback(t *testing.T) {
744+ ctx := t.Context()
745+
746+ paneID := entity.PaneID("pane-1")
747+ tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(paneID))
748+ bwTabs := entity.NewTabList()
749+ bwTabs.Add(tab)
750+ bwTabs.SetActive(tab.ID)
751+
752+ ws := entity.NewWorkspace("ws-1", entity.NewPane(paneID))
753+ ws.ActivePaneID = paneID
754+ tab.Workspace = ws
755+
756+ bw := &browserWindow{
757+ id: "window-1",
758+ tabs: bwTabs,
759+ mainWindow: &window.MainWindow{},
760+ historySidebar: &component.HistorySidebar{},
761+ sidebarVisible: true,
762+ }
763+
764+ // Create a minimal workspace view.
765+ wsView := &component.WorkspaceView{}
766+ // We set up the app so that hideAndRestoreFocusForBrowserWindow
767+ // can find the wsView and call FocusPane on it.
768+ // Since FocusPane is a method on WorkspaceView that requires GTK,
769+ // we verify the state changes that happen before FocusPane:
770+ // sidebarVisible must be toggled to false.
771+
772+ app := &App{
773+ tabs: entity.NewTabList(),
774+ browserWindows: map[string]*browserWindow{bw.id: bw},
775+ workspaceViews: map[entity.TabID]*component.WorkspaceView{
776+ tab.ID: wsView,
777+ },
778+ }
779+ cfg := app.buildHistorySidebarConfig(ctx, bw)
780+
781+ // OnClose hides the sidebar.
782+ cfg.OnClose()
783+
784+ assert.False(t, bw.sidebarVisible, "sidebar must be hidden after close")
785+}
786+
787+// TestApp_HistorySidebarConfig_CloseWithNoSidebarIsSafe verifies that
788+// OnClose (hideAndRestoreFocusForBrowserWindow) is safe when the browser
789+// window has no sidebar or is nil.
790+func TestApp_HistorySidebarConfig_CloseWithNoSidebarIsSafe(t *testing.T) {
791+ ctx := t.Context()
792+
793+ bw := &browserWindow{
794+ id: "no-sidebar",
795+ mainWindow: &window.MainWindow{},
796+ // historySidebar is nil
797+ }
798+
799+ app := &App{
800+ tabs: entity.NewTabList(),
801+ browserWindows: map[string]*browserWindow{bw.id: bw},
802+ }
803+
804+ cfg := app.buildHistorySidebarConfig(ctx, bw)
805+
806+ // Should not panic even with nil sidebar.
807+ require.NotPanics(t, func() { cfg.OnClose() })
808+}
809+
810+// TestApp_HideAndRestoreFocusForBrowserWindow_HidesSidebar verifies that
811+// hideAndRestoreFocusForBrowserWindow hides the sidebar.
812+func TestApp_HideAndRestoreFocusForBrowserWindow_HidesSidebar(t *testing.T) {
813+ paneID := entity.PaneID("pane-1")
814+ tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(paneID))
815+ bwTabs := entity.NewTabList()
816+ bwTabs.Add(tab)
817+ bwTabs.SetActive(tab.ID)
818+
819+ ws := entity.NewWorkspace("ws-1", entity.NewPane(paneID))
820+ ws.ActivePaneID = paneID
821+ tab.Workspace = ws
822+
823+ bw := &browserWindow{
824+ id: "window-1",
825+ tabs: bwTabs,
826+ mainWindow: &window.MainWindow{},
827+ historySidebar: &component.HistorySidebar{},
828+ sidebarVisible: true,
829+ }
830+
831+ wsView := &component.WorkspaceView{}
832+
833+ app := &App{
834+ tabs: entity.NewTabList(),
835+ browserWindows: map[string]*browserWindow{bw.id: bw},
836+ workspaceViews: map[entity.TabID]*component.WorkspaceView{
837+ tab.ID: wsView,
838+ },
839+ }
840+ app.tabs.Add(tab)
841+
842+ // Call hideAndRestoreFocusForBrowserWindow directly.
843+ app.hideAndRestoreFocusForBrowserWindow(bw)
844+
845+ // Sidebar must be hidden.
846+ assert.False(t, bw.sidebarVisible, "sidebar must be hidden")
847+
848+ _ = wsView
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 +0 -878
@@ -8,14 +8,9 @@ import (
88 "unsafe"
99
1010 "github.com/bnema/dumber/internal/application/port"
11- "github.com/bnema/dumber/internal/application/usecase"
1211 "github.com/bnema/dumber/internal/domain/entity"
13- "github.com/bnema/dumber/internal/infrastructure/config"
1412 "github.com/bnema/dumber/internal/shared/syncdispatch"
1513 "github.com/bnema/dumber/internal/ui/component"
16- "github.com/bnema/dumber/internal/ui/coordinator"
17- contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"
18- "github.com/bnema/dumber/internal/ui/dispatcher"
1914 "github.com/bnema/dumber/internal/ui/focus"
2015 "github.com/bnema/dumber/internal/ui/input"
2116 "github.com/bnema/dumber/internal/ui/layout"
@@ -902,876 +897,3 @@ func TestRestoreSession_ActiveWindowIndexSyncsState(t *testing.T) {
902897 assert.Equal(t, entity.WindowID("active-w2"), result[idx].WindowID,
903898 "window at active index must match focused window ID")
904899 }
905-
906-// =============================================================================
907-// History sidebar integration tests
908-// =============================================================================
909-
910-// TestHistorySidebarConfig_OnNavigateNavigatesActivePaneAndKeepsSidebar verifies
911-// that the default OnNavigate callback targets the owning browser window's
912-// active pane and keeps the sidebar visible.
913-func TestHistorySidebarConfig_OnNavigateNavigatesActivePaneAndKeepsSidebar(t *testing.T) {
914- ctx := t.Context()
915-
916- // Build two browser windows with independent tabs and panes.
917- tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
918- tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
919-
920- firstTabs := entity.NewTabList()
921- firstTabs.Add(tab1)
922- firstTabs.SetActive(tab1.ID)
923- first := &browserWindow{id: "window-1", tabs: firstTabs}
924-
925- secondTabs := entity.NewTabList()
926- secondTabs.Add(tab2)
927- secondTabs.SetActive(tab2.ID)
928- second := &browserWindow{id: "window-2", tabs: secondTabs}
929-
930- // Create fake webviews, one per pane.
931- fakeWv1 := &fakeRecordingWebView{id: 1}
932- fakeWv2 := &fakeRecordingWebView{id: 2}
933-
934- contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
935- contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), fakeWv1)
936- contentCoord.RegisterPopupWebView(entity.PaneID("pane-2"), fakeWv2)
937-
938- navCoord := coordinator.NewNavigationCoordinator(ctx, nil, contentCoord)
939-
940- app := &App{
941- tabs: entity.NewTabList(),
942- browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
943- lastFocusedWindowID: first.id,
944- contentCoord: contentCoord,
945- navCoord: navCoord,
946- workspaceViews: map[entity.TabID]*component.WorkspaceView{
947- tab1.ID: {},
948- tab2.ID: {},
949- },
950- }
951- app.tabs.Add(tab1)
952- app.tabs.Add(tab2)
953-
954- second.mainWindow = &window.MainWindow{}
955- second.historySidebar = &component.HistorySidebar{}
956- second.sidebarVisible = true
957-
958- cfg := app.buildHistorySidebarConfig(ctx, second)
959- navigateURL := "https://example.com"
960- err := cfg.OnNavigate(ctx, navigateURL)
961- require.NoError(t, err, "OnNavigate should succeed")
962-
963- // The second window's webview must have received the navigation.
964- assert.True(t, fakeWv2.loadURICalled, "second window webview should receive navigation")
965- assert.Equal(t, navigateURL, fakeWv2.loadURILastURI)
966- assert.True(t, second.sidebarVisible, "default history activation should keep the sidebar visible")
967-
968- // The first window's webview must NOT have been touched (stale-focus guard).
969- assert.False(t, fakeWv1.loadURICalled, "first window webview must not receive navigation from second window callback")
970-}
971-
972-// TestHistorySidebarConfig_OnNavigateKeepOpenNavigatesWithoutClosing verifies
973-// that the OnNavigateKeepOpen callback (Ctrl+Enter) navigates the owning window's
974-// active pane and keeps the sidebar visible.
975-func TestHistorySidebarConfig_OnNavigateKeepOpenNavigatesWithoutClosing(t *testing.T) {
976- ctx := t.Context()
977-
978- tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
979- bwTabs := entity.NewTabList()
980- bwTabs.Add(tab)
981- bwTabs.SetActive(tab.ID)
982-
983- bw := &browserWindow{id: "window-1", tabs: bwTabs}
984-
985- contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
986- fakeWv := &fakeRecordingWebView{id: 1}
987- contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), fakeWv)
988- navCoord := coordinator.NewNavigationCoordinator(ctx, nil, contentCoord)
989-
990- app := &App{
991- tabs: entity.NewTabList(),
992- browserWindows: map[string]*browserWindow{bw.id: bw},
993- contentCoord: contentCoord,
994- navCoord: navCoord,
995- workspaceViews: map[entity.TabID]*component.WorkspaceView{
996- tab.ID: {},
997- },
998- }
999- app.tabs.Add(tab)
1000-
1001- cfg := app.buildHistorySidebarConfig(ctx, bw)
1002- navigateURL := "https://keep-open.com"
1003- err := cfg.OnNavigateKeepOpen(ctx, navigateURL)
1004- require.NoError(t, err)
1005- assert.True(t, fakeWv.loadURICalled)
1006- assert.Equal(t, navigateURL, fakeWv.loadURILastURI, "Ctrl+Enter navigation should go to the URL")
1007-}
1008-
1009-// TestHistorySidebar_OwnershipOnMultiWindowNavigation verifies that when
1010-// multiple browser windows have history sidebars, navigation targets the
1011-// correct owning window's active pane. This tests the stale-focus scenario
1012-// where a different window is globally focused.
1013-func TestHistorySidebar_OwnershipOnMultiWindowNavigation(t *testing.T) {
1014- ctx := t.Context()
1015-
1016- // Two windows, each with their own tab and pane.
1017- tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
1018- tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
1019-
1020- firstTabs := entity.NewTabList()
1021- firstTabs.Add(tab1)
1022- firstTabs.SetActive(tab1.ID)
1023- first := &browserWindow{id: "window-1", tabs: firstTabs}
1024-
1025- secondTabs := entity.NewTabList()
1026- secondTabs.Add(tab2)
1027- secondTabs.SetActive(tab2.ID)
1028- second := &browserWindow{id: "window-2", tabs: secondTabs}
1029-
1030- fakeWv1 := &fakeRecordingWebView{id: 1}
1031- fakeWv2 := &fakeRecordingWebView{id: 2}
1032-
1033- contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
1034- contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), fakeWv1)
1035- contentCoord.RegisterPopupWebView(entity.PaneID("pane-2"), fakeWv2)
1036-
1037- navCoord := coordinator.NewNavigationCoordinator(ctx, nil, contentCoord)
1038-
1039- app := &App{
1040- tabs: entity.NewTabList(),
1041- browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
1042- lastFocusedWindowID: first.id, // STALE: first is globally focused
1043- contentCoord: contentCoord,
1044- navCoord: navCoord,
1045- workspaceViews: map[entity.TabID]*component.WorkspaceView{
1046- tab1.ID: {},
1047- tab2.ID: {},
1048- },
1049- }
1050- app.tabs.Add(tab1)
1051- app.tabs.Add(tab2)
1052-
1053- // Navigation from the SECOND window should target pane-2 even though
1054- // the first window is globally focused (stale focus).
1055- err := app.navigateFromBrowserWindow(ctx, second, "https://second-window.com")
1056- require.NoError(t, err)
1057-
1058- // Second window's webview must receive the navigation.
1059- assert.True(t, fakeWv2.loadURICalled, "second window webview should receive navigation")
1060- assert.Equal(t, "https://second-window.com", fakeWv2.loadURILastURI)
1061-
1062- // First window's webview must NOT have been touched.
1063- assert.False(t, fakeWv1.loadURICalled, "first window webview should NOT receive navigation when second was targeted")
1064-}
1065-
1066-// =============================================================================
1067-// History sidebar toggle state tests
1068-// =============================================================================
1069-
1070-// TestBrowserWindow_HistorySidebarToggle_NilIsNoOp verifies that when
1071-// browserWindow.historySidebar is nil, toggleHistorySidebar is a safe
1072-// no-op and sidebarVisible stays / remains false.
1073-func TestBrowserWindow_HistorySidebarToggle_NilIsNoOp(t *testing.T) {
1074- t.Parallel()
1075-
1076- bw := &browserWindow{id: "test-window", sidebarVisible: false}
1077- require.Nil(t, bw.historySidebar, "historySidebar must be nil for this test")
1078-
1079- // Should not panic even though historySidebar is nil.
1080- bw.toggleHistorySidebar()
1081- assert.False(t, bw.sidebarVisible, "sidebarVisible must remain false when historySidebar is nil")
1082-
1083- // Calling again also safe.
1084- bw.toggleHistorySidebar()
1085- assert.False(t, bw.sidebarVisible, "sidebarVisible must remain false on second toggle")
1086-}
1087-
1088-// TestBrowserWindow_HistorySidebarToggle_FlipsSidebarVisible verifies that
1089-// toggleHistorySidebar correctly flips sidebarVisible when the sidebar
1090-// has been set. Uses a zero-value HistorySidebar (all nil-checked methods
1091-// are safe to call).
1092-func TestBrowserWindow_HistorySidebarToggle_FlipsSidebarVisible(t *testing.T) {
1093- t.Parallel()
1094-
1095- bw := &browserWindow{
1096- id: "test-window",
1097- mainWindow: &window.MainWindow{},
1098- historySidebar: &component.HistorySidebar{},
1099- sidebarVisible: false,
1100- }
1101-
1102- bw.toggleHistorySidebar()
1103- assert.True(t, bw.sidebarVisible, "sidebarVisible must be true after first toggle")
1104-
1105- bw.toggleHistorySidebar()
1106- assert.False(t, bw.sidebarVisible, "sidebarVisible must be false after second toggle")
1107-
1108- bw.toggleHistorySidebar()
1109- assert.True(t, bw.sidebarVisible, "sidebarVisible must be true after third toggle")
1110-}
1111-
1112-// TestBrowserWindow_HistorySidebarShowHide_TransitionsSidebarVisible
1113-// verifies that showHistorySidebar and hideHistorySidebar independently
1114-// set sidebarVisible to true and false respectively.
1115-func TestBrowserWindow_HistorySidebarShowHide_TransitionsSidebarVisible(t *testing.T) {
1116- t.Parallel()
1117-
1118- bw := &browserWindow{
1119- id: "test-window",
1120- mainWindow: &window.MainWindow{},
1121- historySidebar: &component.HistorySidebar{},
1122- sidebarVisible: false,
1123- }
1124-
1125- // Show sets visible
1126- bw.showHistorySidebar()
1127- assert.True(t, bw.sidebarVisible, "sidebarVisible must be true after show")
1128-
1129- // Hide clears visible
1130- bw.hideHistorySidebar()
1131- assert.False(t, bw.sidebarVisible, "sidebarVisible must be false after hide")
1132-
1133- // Redundant hide is idempotent
1134- bw.hideHistorySidebar()
1135- assert.False(t, bw.sidebarVisible, "sidebarVisible must remain false after redundant hide")
1136-
1137- // Show again
1138- bw.showHistorySidebar()
1139- assert.True(t, bw.sidebarVisible, "sidebarVisible must be true after second show")
1140-}
1141-
1142-// TestBrowserWindow_HistorySidebarShowHide_NilSidebarIsNoOp verifies that
1143-// show/hide do not panic when historySidebar is nil.
1144-func TestBrowserWindow_HistorySidebarShowHide_NilSidebarIsNoOp(t *testing.T) {
1145- t.Parallel()
1146-
1147- bw := &browserWindow{id: "test-window"}
1148- require.Nil(t, bw.historySidebar)
1149-
1150- // Should not panic even though historySidebar is nil.
1151- bw.showHistorySidebar()
1152- assert.False(t, bw.sidebarVisible, "sidebarVisible must remain false when nil")
1153-
1154- bw.hideHistorySidebar()
1155- assert.False(t, bw.sidebarVisible, "sidebarVisible must remain false when nil")
1156-}
1157-
1158-// =============================================================================
1159-// App wiring: toggle handler uses lastFocusedBrowserWindow
1160-// =============================================================================
1161-
1162-// TestApp_HistorySidebarToggleHandlerUsesLastFocusedWindow verifies that
1163-// the toggle handler wired in App.wireKeyboardActions picks the
1164-// lastFocusedBrowserWindow and calls toggleHistorySidebar on it.
1165-func TestApp_HistorySidebarToggleHandlerUsesLastFocusedWindow(t *testing.T) {
1166- // Two windows, only the focused one has a history sidebar.
1167- focusedBW := &browserWindow{
1168- id: "focused",
1169- mainWindow: &window.MainWindow{},
1170- historySidebar: &component.HistorySidebar{},
1171- sidebarVisible: false,
1172- }
1173- otherBW := &browserWindow{
1174- id: "other",
1175- mainWindow: &window.MainWindow{},
1176- }
1177-
1178- app := &App{
1179- browserWindows: map[string]*browserWindow{
1180- focusedBW.id: focusedBW,
1181- otherBW.id: otherBW,
1182- },
1183- lastFocusedWindowID: focusedBW.id,
1184- }
1185-
1186- // Simulate the toggle handler that wireKeyboardActions registers:
1187- // a.kbDispatcher.SetOnToggleHistorySidebar(func(ctx context.Context) error {
1188- // bw := a.lastFocusedBrowserWindow()
1189- // ...
1190- // })
1191- bw := app.lastFocusedBrowserWindow()
1192- require.NotNil(t, bw, "lastFocusedBrowserWindow must not be nil")
1193- require.Equal(t, focusedBW.id, bw.id, "must return the focused window")
1194- require.NotNil(t, bw.historySidebar, "focused window must have a history sidebar")
1195-
1196- // Toggle on the focused window.
1197- bw.toggleHistorySidebar()
1198- assert.True(t, bw.sidebarVisible, "sidebar on focused window must become visible")
1199-
1200- // The other window must remain untouched.
1201- assert.False(t, otherBW.sidebarVisible, "other window sidebar must remain invisible")
1202-
1203- // Toggle again: focused window sidebar hides.
1204- bw.toggleHistorySidebar()
1205- assert.False(t, bw.sidebarVisible, "sidebar on focused window must hide on second toggle")
1206-}
1207-
1208-// TestApp_HistorySidebarToggleHandler_NilFocusedWindowIsNoOp verifies
1209-// that the toggle handler is safe when lastFocusedBrowserWindow returns nil.
1210-func TestApp_HistorySidebarToggleHandler_NilFocusedWindowIsNoOp(t *testing.T) {
1211- app := &App{
1212- browserWindows: make(map[string]*browserWindow),
1213- lastFocusedWindowID: "missing",
1214- }
1215-
1216- // The handler should return early without error when bw is nil.
1217- bw := app.lastFocusedBrowserWindow()
1218- require.Nil(t, bw, "lastFocusedBrowserWindow should return nil for missing window")
1219-}
1220-
1221-// =============================================================================
1222-// History sidebar config callbacks: focus restoration on close
1223-// =============================================================================
1224-
1225-// TestHistorySidebarConfig_OnCloseHidesSidebar verifies that the OnClose
1226-// callback hides the sidebar for the owning browser window.
1227-func TestHistorySidebarConfig_OnCloseHidesSidebar(t *testing.T) {
1228- ctx := t.Context()
1229- tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
1230- bwTabs := entity.NewTabList()
1231- bwTabs.Add(tab)
1232- bwTabs.SetActive(tab.ID)
1233-
1234- bw := &browserWindow{
1235- id: "window-1",
1236- tabs: bwTabs,
1237- mainWindow: &window.MainWindow{},
1238- historySidebar: &component.HistorySidebar{},
1239- sidebarVisible: true,
1240- }
1241-
1242- app := &App{
1243- tabs: entity.NewTabList(),
1244- browserWindows: map[string]*browserWindow{bw.id: bw},
1245- workspaceViews: map[entity.TabID]*component.WorkspaceView{tab.ID: {}},
1246- }
1247- app.tabs.Add(tab)
1248-
1249- cfg := app.buildHistorySidebarConfig(ctx, bw)
1250- cfg.OnClose()
1251-
1252- assert.False(t, bw.sidebarVisible, "sidebar must be hidden by OnClose")
1253-}
1254-
1255-// =============================================================================
1256-// Dispatcher-backed Ctrl+H integration test
1257-// =============================================================================
1258-
1259-// TestApp_HistorySidebar_ToggleThroughKeyboardDispatcher wires the real toggle
1260-// handler from App.wireKeyboardActions through the KeyboardDispatcher and
1261-// dispatches ActionToggleHistorySystemView, asserting the focused browser
1262-// window's sidebar visibility toggles.
1263-func TestApp_HistorySidebar_ToggleThroughKeyboardDispatcher(t *testing.T) {
1264- ctx := t.Context()
1265-
1266- focusedBW := &browserWindow{
1267- id: "focused",
1268- mainWindow: &window.MainWindow{},
1269- historySidebar: &component.HistorySidebar{},
1270- sidebarVisible: false,
1271- }
1272- otherBW := &browserWindow{
1273- id: "other",
1274- mainWindow: &window.MainWindow{},
1275- }
1276-
1277- app := &App{
1278- browserWindows: map[string]*browserWindow{
1279- focusedBW.id: focusedBW,
1280- otherBW.id: otherBW,
1281- },
1282- lastFocusedWindowID: focusedBW.id,
1283- }
1284-
1285- // Create a KeyboardDispatcher and wire the production toggle handler.
1286- kbDispatcher := dispatcher.NewKeyboardDispatcher(
1287- ctx,
1288- &coordinator.WorkspaceCoordinator{},
1289- &coordinator.NavigationCoordinator{},
1290- nil, nil,
1291- dispatcher.KeyboardActions{},
1292- func(context.Context) entity.PaneID { return "" },
1293- )
1294-
1295- kbDispatcher.SetOnToggleHistorySidebar(app.toggleHistorySidebarAction)
1296-
1297- // First dispatch: toggle ON.
1298- err := kbDispatcher.Dispatch(ctx, input.ActionToggleHistorySystemView)
1299- require.NoError(t, err)
1300- assert.True(t, focusedBW.sidebarVisible, "focused window sidebar must be visible after toggle")
1301- assert.False(t, otherBW.sidebarVisible, "other window sidebar must remain invisible")
1302-
1303- // Second dispatch: toggle OFF.
1304- err = kbDispatcher.Dispatch(ctx, input.ActionToggleHistorySystemView)
1305- require.NoError(t, err)
1306- assert.False(t, focusedBW.sidebarVisible, "focused window sidebar must be hidden after second toggle")
1307-}
1308-
1309-// TestApp_HistorySidebar_ToggleThroughDispatcher_UnavailableReturnsError verifies that
1310-// when the focused window has no history sidebar, Ctrl+H returns a clean error.
1311-func TestApp_HistorySidebar_ToggleThroughDispatcher_UnavailableReturnsError(t *testing.T) {
1312- ctx := t.Context()
1313-
1314- bw := &browserWindow{
1315- id: "no-sidebar",
1316- mainWindow: &window.MainWindow{},
1317- // historySidebar is nil
1318- }
1319-
1320- app := &App{
1321- browserWindows: map[string]*browserWindow{bw.id: bw},
1322- lastFocusedWindowID: bw.id,
1323- }
1324-
1325- kbDispatcher := dispatcher.NewKeyboardDispatcher(
1326- ctx,
1327- &coordinator.WorkspaceCoordinator{},
1328- &coordinator.NavigationCoordinator{},
1329- nil, nil,
1330- dispatcher.KeyboardActions{},
1331- func(context.Context) entity.PaneID { return "" },
1332- )
1333-
1334- kbDispatcher.SetOnToggleHistorySidebar(app.toggleHistorySidebarAction)
1335-
1336- err := kbDispatcher.Dispatch(ctx, input.ActionToggleHistorySystemView)
1337- require.Error(t, err)
1338- assert.ErrorContains(t, err, "history sidebar unavailable")
1339- assert.False(t, bw.sidebarVisible, "sidebar must remain invisible when not wired")
1340-}
1341-
1342-// TestApp_HistorySidebar_ToggleThroughDispatcher_NilFocusedReturnsError verifies
1343-// that the toggle handler returns a clean error when there is no focused window.
1344-func TestApp_HistorySidebar_ToggleThroughDispatcher_NilFocusedReturnsError(t *testing.T) {
1345- ctx := t.Context()
1346-
1347- app := &App{
1348- browserWindows: make(map[string]*browserWindow),
1349- lastFocusedWindowID: "missing",
1350- }
1351-
1352- kbDispatcher := dispatcher.NewKeyboardDispatcher(
1353- ctx,
1354- &coordinator.WorkspaceCoordinator{},
1355- &coordinator.NavigationCoordinator{},
1356- nil, nil,
1357- dispatcher.KeyboardActions{},
1358- func(context.Context) entity.PaneID { return "" },
1359- )
1360-
1361- kbDispatcher.SetOnToggleHistorySidebar(app.toggleHistorySidebarAction)
1362-
1363- err := kbDispatcher.Dispatch(ctx, input.ActionToggleHistorySystemView)
1364- require.Error(t, err)
1365- assert.ErrorContains(t, err, "history sidebar unavailable")
1366-}
1367-
1368-// =============================================================================
1369-// buildHistorySidebarConfig callback seam tests
1370-// =============================================================================
1371-
1372-// TestApp_HistorySidebarConfig_NavigateCallback verifies that the OnNavigate
1373-// callback from buildHistorySidebarConfig navigates the owning browser window's
1374-// active pane to the given URL.
1375-func TestApp_HistorySidebarConfig_NavigateCallbackNavigates(t *testing.T) {
1376- ctx := t.Context()
1377-
1378- paneID := entity.PaneID("pane-1")
1379- tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(paneID))
1380- bwTabs := entity.NewTabList()
1381- bwTabs.Add(tab)
1382- bwTabs.SetActive(tab.ID)
1383-
1384- bw := &browserWindow{
1385- id: "window-1",
1386- tabs: bwTabs,
1387- mainWindow: &window.MainWindow{},
1388- historySidebar: &component.HistorySidebar{},
1389- sidebarVisible: true,
1390- }
1391-
1392- fakeWv := &fakeRecordingWebView{id: 1}
1393- contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
1394- contentCoord.RegisterPopupWebView(paneID, fakeWv)
1395- navCoord := coordinator.NewNavigationCoordinator(ctx, nil, contentCoord)
1396-
1397- app := &App{
1398- tabs: entity.NewTabList(),
1399- browserWindows: map[string]*browserWindow{bw.id: bw},
1400- contentCoord: contentCoord,
1401- navCoord: navCoord,
1402- workspaceViews: map[entity.TabID]*component.WorkspaceView{
1403- tab.ID: {},
1404- },
1405- }
1406- app.tabs.Add(tab)
1407-
1408- // Build the config using the extracted seam.
1409- cfg := app.buildHistorySidebarConfig(ctx, bw)
1410- require.NotNil(t, cfg.OnNavigate, "OnNavigate callback must be non-nil")
1411-
1412- // Invoke the OnNavigate callback.
1413- navigateURL := "https://navigated.com"
1414- err := cfg.OnNavigate(ctx, navigateURL)
1415- require.NoError(t, err)
1416-
1417- // Verify the navigation reached the correct webview.
1418- assert.True(t, fakeWv.loadURICalled, "webview must receive navigation")
1419- assert.Equal(t, navigateURL, fakeWv.loadURILastURI)
1420- assert.True(t, bw.sidebarVisible, "default history navigation should keep the sidebar open")
1421-}
1422-
1423-func TestApp_NavigateHistorySidebarSelection_KeepsSidebarVisible(t *testing.T) {
1424- ctx := t.Context()
1425-
1426- paneID := entity.PaneID("pane-1")
1427- tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(paneID))
1428- bwTabs := entity.NewTabList()
1429- bwTabs.Add(tab)
1430- bwTabs.SetActive(tab.ID)
1431-
1432- bw := &browserWindow{
1433- id: "window-1",
1434- tabs: bwTabs,
1435- mainWindow: &window.MainWindow{},
1436- historySidebar: &component.HistorySidebar{},
1437- sidebarVisible: true,
1438- }
1439-
1440- fakeWv := &fakeRecordingWebView{id: 1}
1441- contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
1442- contentCoord.RegisterPopupWebView(paneID, fakeWv)
1443- navCoord := coordinator.NewNavigationCoordinator(ctx, nil, contentCoord)
1444-
1445- app := &App{
1446- tabs: entity.NewTabList(),
1447- browserWindows: map[string]*browserWindow{bw.id: bw},
1448- contentCoord: contentCoord,
1449- navCoord: navCoord,
1450- workspaceViews: map[entity.TabID]*component.WorkspaceView{
1451- tab.ID: {},
1452- },
1453- }
1454- app.tabs.Add(tab)
1455-
1456- err := app.navigateHistorySidebarSelection(ctx, bw, "https://open.com")
1457- require.NoError(t, err)
1458- assert.True(t, fakeWv.loadURICalled)
1459- assert.Equal(t, "https://open.com", fakeWv.loadURILastURI)
1460- assert.True(t, bw.sidebarVisible, "history selection navigation should not hide the sidebar")
1461-}
1462-
1463-// TestApp_HistorySidebarConfig_NavigateCallbackOwnership verifies that
1464-// OnNavigate targets the callback's owning window, not the globally focused
1465-// window, when they differ.
1466-func TestApp_HistorySidebarConfig_NavigateCallbackOwnership(t *testing.T) {
1467- ctx := t.Context()
1468-
1469- tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
1470- tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
1471-
1472- firstTabs := entity.NewTabList()
1473- firstTabs.Add(tab1)
1474- firstTabs.SetActive(tab1.ID)
1475- first := &browserWindow{id: "window-1", tabs: firstTabs}
1476-
1477- secondTabs := entity.NewTabList()
1478- secondTabs.Add(tab2)
1479- secondTabs.SetActive(tab2.ID)
1480- second := &browserWindow{id: "window-2", tabs: secondTabs}
1481-
1482- fakeWv1 := &fakeRecordingWebView{id: 1}
1483- fakeWv2 := &fakeRecordingWebView{id: 2}
1484-
1485- contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
1486- contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), fakeWv1)
1487- contentCoord.RegisterPopupWebView(entity.PaneID("pane-2"), fakeWv2)
1488- navCoord := coordinator.NewNavigationCoordinator(ctx, nil, contentCoord)
1489-
1490- app := &App{
1491- tabs: entity.NewTabList(),
1492- browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
1493- lastFocusedWindowID: first.id, // STALE: first is globally focused
1494- contentCoord: contentCoord,
1495- navCoord: navCoord,
1496- workspaceViews: map[entity.TabID]*component.WorkspaceView{
1497- tab1.ID: {},
1498- tab2.ID: {},
1499- },
1500- }
1501- app.tabs.Add(tab1)
1502- app.tabs.Add(tab2)
1503-
1504- // Build config for the SECOND window, even though first is globally focused.
1505- cfg := app.buildHistorySidebarConfig(ctx, second)
1506-
1507- // Invoke OnNavigate — should navigate through second window's pane-2.
1508- err := cfg.OnNavigate(ctx, "https://ownership.com")
1509- require.NoError(t, err)
1510-
1511- // Second window's webview must receive navigation.
1512- assert.True(t, fakeWv2.loadURICalled, "second window webview must receive navigation")
1513- assert.Equal(t, "https://ownership.com", fakeWv2.loadURILastURI)
1514-
1515- // First window's webview must NOT be touched.
1516- assert.False(t, fakeWv1.loadURICalled, "first window webview must not receive navigation")
1517-}
1518-
1519-// TestApp_HistorySidebarConfig_KeepOpenCallback verifies that
1520-// OnNavigateKeepOpen navigates the owning window without hiding the sidebar.
1521-func TestApp_HistorySidebarConfig_KeepOpenCallback(t *testing.T) {
1522- ctx := t.Context()
1523-
1524- paneID := entity.PaneID("pane-1")
1525- tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(paneID))
1526- bwTabs := entity.NewTabList()
1527- bwTabs.Add(tab)
1528- bwTabs.SetActive(tab.ID)
1529-
1530- bw := &browserWindow{
1531- id: "window-1",
1532- tabs: bwTabs,
1533- mainWindow: &window.MainWindow{},
1534- historySidebar: &component.HistorySidebar{},
1535- sidebarVisible: true,
1536- }
1537-
1538- fakeWv := &fakeRecordingWebView{id: 1}
1539- contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
1540- contentCoord.RegisterPopupWebView(paneID, fakeWv)
1541- navCoord := coordinator.NewNavigationCoordinator(ctx, nil, contentCoord)
1542-
1543- app := &App{
1544- tabs: entity.NewTabList(),
1545- browserWindows: map[string]*browserWindow{bw.id: bw},
1546- contentCoord: contentCoord,
1547- navCoord: navCoord,
1548- workspaceViews: map[entity.TabID]*component.WorkspaceView{
1549- tab.ID: {},
1550- },
1551- }
1552- app.tabs.Add(tab)
1553-
1554- cfg := app.buildHistorySidebarConfig(ctx, bw)
1555-
1556- // OnNavigateKeepOpen navigates but does NOT close the sidebar.
1557- navigateURL := "https://keep-open.com"
1558- err := cfg.OnNavigateKeepOpen(ctx, navigateURL)
1559- require.NoError(t, err)
1560-
1561- assert.True(t, fakeWv.loadURICalled, "webview must receive navigation")
1562- assert.Equal(t, navigateURL, fakeWv.loadURILastURI)
1563-
1564- // Sidebar must remain visible (keep-open contract).
1565- assert.True(t, bw.sidebarVisible, "sidebar must stay visible after keep-open navigation")
1566-}
1567-
1568-// TestApp_HistorySidebarConfig_OpenInNewPaneCallback verifies that
1569-// OnOpenInNewPane activates the owning browser window and creates a split
1570-// with the target URL.
1571-func TestApp_HistorySidebarConfig_OpenInNewPaneCallback(t *testing.T) {
1572- ctx := t.Context()
1573-
1574- paneID := entity.PaneID("pane-1")
1575- tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(paneID))
1576- bwTabs := entity.NewTabList()
1577- bwTabs.Add(tab)
1578- bwTabs.SetActive(tab.ID)
1579-
1580- ws := entity.NewWorkspace("ws-1", entity.NewPane(paneID))
1581- ws.ActivePaneID = paneID
1582- tab.Workspace = ws
1583-
1584- bw := &browserWindow{id: "window-1", tabs: bwTabs}
1585-
1586- panesUC := usecase.NewManagePanesUseCase(func() string { return "pane-2" })
1587- wsCoord := coordinator.NewWorkspaceCoordinator(ctx, coordinator.WorkspaceCoordinatorConfig{
1588- PanesUC: panesUC,
1589- GetActiveWS: func() (*entity.Workspace, *component.WorkspaceView) {
1590- return ws, nil
1591- },
1592- })
1593-
1594- app := &App{
1595- tabs: entity.NewTabList(),
1596- browserWindows: map[string]*browserWindow{bw.id: bw},
1597- wsCoord: wsCoord,
1598- workspaceViews: map[entity.TabID]*component.WorkspaceView{
1599- tab.ID: {},
1600- },
1601- }
1602- app.tabs.Add(tab)
1603-
1604- cfg := app.buildHistorySidebarConfig(ctx, bw)
1605-
1606- // OnOpenInNewPane should activate the owning window and split with URL.
1607- splitURL := "https://shift-enter.com"
1608- err := cfg.OnOpenInNewPane(ctx, splitURL)
1609- require.NoError(t, err)
1610-
1611- // After split, workspace should have 2 panes.
1612- require.Equal(t, 2, ws.PaneCount(), "workspace should have 2 panes after split")
1613-
1614- // The new pane should have the split URL.
1615- allPanes := ws.AllPanes()
1616- var newPane *entity.Pane
1617- for _, p := range allPanes {
1618- if p != nil && p.ID != paneID {
1619- newPane = p
1620- break
1621- }
1622- }
1623- require.NotNil(t, newPane, "new pane must exist after split")
1624- assert.Equal(t, splitURL, newPane.URI, "new pane must have the split URL")
1625-}
1626-
1627-// TestApp_HistorySidebarConfig_CloseCallback verifies that OnClose hides the
1628-// sidebar for the owning browser window and restores focus to the active pane.
1629-func TestApp_HistorySidebarConfig_CloseCallback(t *testing.T) {
1630- ctx := t.Context()
1631-
1632- paneID := entity.PaneID("pane-1")
1633- tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(paneID))
1634- bwTabs := entity.NewTabList()
1635- bwTabs.Add(tab)
1636- bwTabs.SetActive(tab.ID)
1637-
1638- ws := entity.NewWorkspace("ws-1", entity.NewPane(paneID))
1639- ws.ActivePaneID = paneID
1640- tab.Workspace = ws
1641-
1642- bw := &browserWindow{
1643- id: "window-1",
1644- tabs: bwTabs,
1645- mainWindow: &window.MainWindow{},
1646- historySidebar: &component.HistorySidebar{},
1647- sidebarVisible: true,
1648- }
1649-
1650- // Create a minimal workspace view.
1651- wsView := &component.WorkspaceView{}
1652- // We set up the app so that hideAndRestoreFocusForBrowserWindow
1653- // can find the wsView and call FocusPane on it.
1654- // Since FocusPane is a method on WorkspaceView that requires GTK,
1655- // we verify the state changes that happen before FocusPane:
1656- // sidebarVisible must be toggled to false.
1657-
1658- app := &App{
1659- tabs: entity.NewTabList(),
1660- browserWindows: map[string]*browserWindow{bw.id: bw},
1661- workspaceViews: map[entity.TabID]*component.WorkspaceView{
1662- tab.ID: wsView,
1663- },
1664- }
1665- cfg := app.buildHistorySidebarConfig(ctx, bw)
1666-
1667- // OnClose hides the sidebar.
1668- cfg.OnClose()
1669-
1670- assert.False(t, bw.sidebarVisible, "sidebar must be hidden after close")
1671-}
1672-
1673-// TestApp_HistorySidebarConfig_CloseWithNoSidebarIsSafe verifies that
1674-// OnClose (hideAndRestoreFocusForBrowserWindow) is safe when the browser
1675-// window has no sidebar or is nil.
1676-func TestApp_HistorySidebarConfig_CloseWithNoSidebarIsSafe(t *testing.T) {
1677- ctx := t.Context()
1678-
1679- bw := &browserWindow{
1680- id: "no-sidebar",
1681- mainWindow: &window.MainWindow{},
1682- // historySidebar is nil
1683- }
1684-
1685- app := &App{
1686- tabs: entity.NewTabList(),
1687- browserWindows: map[string]*browserWindow{bw.id: bw},
1688- }
1689-
1690- cfg := app.buildHistorySidebarConfig(ctx, bw)
1691-
1692- // Should not panic even with nil sidebar.
1693- require.NotPanics(t, func() { cfg.OnClose() })
1694-}
1695-
1696-// TestApp_HideAndRestoreFocusForBrowserWindow_HidesSidebar verifies that
1697-// hideAndRestoreFocusForBrowserWindow hides the sidebar.
1698-func TestApp_HideAndRestoreFocusForBrowserWindow_HidesSidebar(t *testing.T) {
1699- paneID := entity.PaneID("pane-1")
1700- tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(paneID))
1701- bwTabs := entity.NewTabList()
1702- bwTabs.Add(tab)
1703- bwTabs.SetActive(tab.ID)
1704-
1705- ws := entity.NewWorkspace("ws-1", entity.NewPane(paneID))
1706- ws.ActivePaneID = paneID
1707- tab.Workspace = ws
1708-
1709- bw := &browserWindow{
1710- id: "window-1",
1711- tabs: bwTabs,
1712- mainWindow: &window.MainWindow{},
1713- historySidebar: &component.HistorySidebar{},
1714- sidebarVisible: true,
1715- }
1716-
1717- wsView := &component.WorkspaceView{}
1718-
1719- app := &App{
1720- tabs: entity.NewTabList(),
1721- browserWindows: map[string]*browserWindow{bw.id: bw},
1722- workspaceViews: map[entity.TabID]*component.WorkspaceView{
1723- tab.ID: wsView,
1724- },
1725- }
1726- app.tabs.Add(tab)
1727-
1728- // Call hideAndRestoreFocusForBrowserWindow directly.
1729- app.hideAndRestoreFocusForBrowserWindow(bw)
1730-
1731- // Sidebar must be hidden.
1732- assert.False(t, bw.sidebarVisible, "sidebar must be hidden")
1733-
1734- _ = wsView
1735-}
1736-
1737-// TestApp_HideAndRestoreFocusForBrowserWindow_NilBWIsSafe verifies that
1738-// hideAndRestoreFocusForBrowserWindow handles nil browser window safely.
1739-func TestApp_HideAndRestoreFocusForBrowserWindow_NilBWIsSafe(t *testing.T) {
1740- app := &App{}
1741- require.NotPanics(t, func() { app.hideAndRestoreFocusForBrowserWindow(nil) })
1742-}
1743-
1744-// =============================================================================
1745-// Sidebar width config tests
1746-// =============================================================================
1747-
1748-func TestHistorySidebarWidthConfig_ConfigValue(t *testing.T) {
1749- cfg := historySidebarWidthConfig(350)
1750- assert.Equal(t, 350, cfg.WidthPx, "should apply config-backed width of 350px")
1751- assert.Equal(t, 280, cfg.MinPx, "should keep default min clamp")
1752- assert.Equal(t, 380, cfg.MaxPx, "should keep default max clamp")
1753-}
1754-
1755-func TestHistorySidebarWidthConfig_DefaultValue(t *testing.T) {
1756- cfg := historySidebarWidthConfig(0)
1757- assert.Equal(t, 320, cfg.WidthPx, "should use default width of 320px when config is 0")
1758- assert.Equal(t, 280, cfg.MinPx, "should keep default min clamp")
1759- assert.Equal(t, 380, cfg.MaxPx, "should keep default max clamp")
1760-}
1761-
1762-// TestBrowserWindow_ApplySidebarWidthConfig_NilMainWindowIsSafe verifies
1763-// that applySidebarWidthConfig handles nil mainWindow without panic.
1764-func TestBrowserWindow_ApplySidebarWidthConfig_NilMainWindowIsSafe(t *testing.T) {
1765- bw := &browserWindow{mainWindow: nil}
1766- app := &App{deps: &Dependencies{Config: &config.Config{SidebarWidth: 300}}}
1767- require.NotPanics(t, func() { bw.applySidebarWidthConfig(app) })
1768-}
1769-
1770-// TestBrowserWindow_ApplySidebarWidthConfig_NilDepsIsSafe verifies
1771-// that applySidebarWidthConfig handles nil deps without panic.
1772-func TestBrowserWindow_ApplySidebarWidthConfig_NilDepsIsSafe(t *testing.T) {
1773- mw := &window.MainWindow{}
1774- bw := &browserWindow{mainWindow: mw}
1775- app := &App{deps: nil}
1776- require.NotPanics(t, func() { bw.applySidebarWidthConfig(app) })
1777-}
M internal/ui/component/history_model.go +73 -97
@@ -139,52 +139,60 @@ func relativeTime(t time.Time) string {
139139 }
140140 }
141141
142-// keyboardNavModel provides pure functions for keyboard navigation over
143-// day-grouped history entries. It has no GTK dependencies and can be tested
144-// directly. A "linear index" refers to the ListBox row position: group
145-// headers occupy one row, followed by each entry row.
142+// historyDisplayRowKind identifies the type of row rendered in the sidebar.
143+type historyDisplayRowKind int
144+
145+const (
146+ historyDisplayRowHeader historyDisplayRowKind = iota
147+ historyDisplayRowEntry
148+)
149+
150+// historyDisplayRow is the explicit row model rendered by the history sidebar.
151+// It is the only source used for row lookup, selection, activation, and delete.
152+type historyDisplayRow struct {
153+ Kind historyDisplayRowKind
154+ Label string
155+ Entry *entity.HistoryEntry
156+ GroupIndex int
157+}
158+
159+func buildHistoryDisplayRows(groups []historyGroup) []historyDisplayRow {
160+ if len(groups) == 0 {
161+ return nil
162+ }
163+ rows := make([]historyDisplayRow, 0)
164+ for gi, group := range groups {
165+ rows = append(rows, historyDisplayRow{Kind: historyDisplayRowHeader, Label: group.Label, GroupIndex: gi})
166+ for _, entry := range group.Entries {
167+ rows = append(rows, historyDisplayRow{Kind: historyDisplayRowEntry, Entry: entry, GroupIndex: gi})
168+ }
169+ }
170+ return rows
171+}
172+
173+// keyboardNavModel provides pure functions for keyboard navigation over the
174+// explicit display rows. It has no GTK dependencies and can be tested directly.
146175 type keyboardNavModel struct {
147- groups []historyGroup
176+ rows []historyDisplayRow
148177 }
149178
150-// newKeyboardNavModel creates a keyboardNavModel over the given groups.
179+// newKeyboardNavModel creates a keyboardNavModel over day-grouped history.
151180 func newKeyboardNavModel(groups []historyGroup) keyboardNavModel {
152- return keyboardNavModel{groups: groups}
181+ return newKeyboardNavModelFromRows(buildHistoryDisplayRows(groups))
153182 }
154183
155-// totalRows returns the number of linear rows (one per group header +
156-// one per entry).
157-func (m keyboardNavModel) totalRows() int {
158- n := 0
159- for _, g := range m.groups {
160- n++ // header
161- n += len(g.Entries)
162- }
163- return n
184+func newKeyboardNavModelFromRows(rows []historyDisplayRow) keyboardNavModel {
185+ return keyboardNavModel{rows: rows}
164186 }
165187
166-// isSelectable returns true when the linear index corresponds to an entry
167-// row (not a group header).
188+// totalRows returns the number of explicit display rows.
189+func (m keyboardNavModel) totalRows() int { return len(m.rows) }
190+
191+// isSelectable returns true when the display row corresponds to an entry.
168192 func (m keyboardNavModel) isSelectable(index int) bool {
169- if index < 0 {
170- return false
171- }
172- linear := 0
173- for _, g := range m.groups {
174- if index == linear {
175- return false // header
176- }
177- linear++ // skip header
178- if index < linear+len(g.Entries) {
179- return true
180- }
181- linear += len(g.Entries)
182- }
183- return false
193+ return index >= 0 && index < len(m.rows) && m.rows[index].Kind == historyDisplayRowEntry && m.rows[index].Entry != nil
184194 }
185195
186-// firstSelectableIndex returns the linear index of the first entry row,
187-// or -1 when there are no entries.
188196 func (m keyboardNavModel) firstSelectableIndex() int {
189197 for i := 0; i < m.totalRows(); i++ {
190198 if m.isSelectable(i) {
@@ -194,8 +202,6 @@ func (m keyboardNavModel) firstSelectableIndex() int {
194202 return -1
195203 }
196204
197-// lastSelectableIndex returns the linear index of the last entry row,
198-// or -1 when there are no entries.
199205 func (m keyboardNavModel) lastSelectableIndex() int {
200206 for i := m.totalRows() - 1; i >= 0; i-- {
201207 if m.isSelectable(i) {
@@ -205,69 +211,56 @@ func (m keyboardNavModel) lastSelectableIndex() int {
205211 return -1
206212 }
207213
208-// nextSelectableIndex returns the next selectable index in direction dir
209-// (-1 or +1), or -1 when there is no further selectable row in that
210-// direction. Skips group headers.
211214 func (m keyboardNavModel) nextSelectableIndex(from, dir int) int {
212215 if dir != -1 && dir != +1 {
213216 return -1
214217 }
215- i := from + dir
216- for i >= 0 && i < m.totalRows() {
218+ for i := from + dir; i >= 0 && i < m.totalRows(); i += dir {
217219 if m.isSelectable(i) {
218220 return i
219221 }
220- i += dir
221222 }
222223 return -1
223224 }
224225
225-// groupIndexAt returns the index of the group containing the given linear row.
226226 func (m keyboardNavModel) groupIndexAt(index int) int {
227- linear := 0
228- for gi, g := range m.groups {
229- if index == linear {
230- return gi // header
231- }
232- linear++ // skip header
233- if index < linear+len(g.Entries) {
234- return gi
227+ if index < 0 || index >= len(m.rows) {
228+ return -1
229+ }
230+ return m.rows[index].GroupIndex
231+}
232+
233+func (m keyboardNavModel) maxGroupIndex() int {
234+ max := -1
235+ for _, row := range m.rows {
236+ if row.GroupIndex > max {
237+ max = row.GroupIndex
235238 }
236- linear += len(g.Entries)
237239 }
238- return -1
240+ return max
239241 }
240242
241-// cumulativeOffsetAtGroup returns the linear index of the group header row
242-// for the group at gi.
243243 func (m keyboardNavModel) cumulativeOffsetAtGroup(gi int) int {
244- if gi < 0 || gi >= len(m.groups) {
244+ if gi < 0 {
245245 return -1
246246 }
247- offset := 0
248- for i := 0; i < gi; i++ {
249- offset += 1 + len(m.groups[i].Entries)
247+ for i, row := range m.rows {
248+ if row.GroupIndex == gi && row.Kind == historyDisplayRowHeader {
249+ return i
250+ }
250251 }
251- return offset
252+ return -1
252253 }
253254
254-// firstEntryOfGroup returns the linear index of the first entry in the
255-// group at gi, or -1 if the group has no entries.
256255 func (m keyboardNavModel) firstEntryOfGroup(gi int) int {
257- if gi < 0 || gi >= len(m.groups) {
258- return -1
259- }
260- offset := m.cumulativeOffsetAtGroup(gi)
261- firstEntry := offset + 1
262- if firstEntry < m.totalRows() && m.isSelectable(firstEntry) {
263- return firstEntry
256+ for i, row := range m.rows {
257+ if row.GroupIndex == gi && row.Kind == historyDisplayRowEntry && row.Entry != nil {
258+ return i
259+ }
264260 }
265261 return -1
266262 }
267263
268-// previousDayBoundary returns the linear index of the first entry in the
269-// day group that precedes the row at fromIndex. Returns -1 when there is
270-// no earlier day group.
271264 func (m keyboardNavModel) previousDayBoundary(from int) int {
272265 gi := m.groupIndexAt(from)
273266 if gi <= 0 {
@@ -276,39 +269,21 @@ func (m keyboardNavModel) previousDayBoundary(from int) int {
276269 return m.firstEntryOfGroup(gi - 1)
277270 }
278271
279-// nextDayBoundary returns the linear index of the first entry in the day
280-// group that follows the row at fromIndex. Returns -1 when there is no
281-// later day group.
282272 func (m keyboardNavModel) nextDayBoundary(from int) int {
283273 gi := m.groupIndexAt(from)
284- if gi < 0 || gi >= len(m.groups)-1 {
274+ if gi < 0 || gi >= m.maxGroupIndex() {
285275 return -1
286276 }
287277 return m.firstEntryOfGroup(gi + 1)
288278 }
289279
290-// entryAt returns the history entry at the given linear index, or nil
291-// when the index is out of range or points at a group header.
292280 func (m keyboardNavModel) entryAt(index int) *entity.HistoryEntry {
293- if index < 0 {
281+ if !m.isSelectable(index) {
294282 return nil
295283 }
296- linear := 0
297- for _, g := range m.groups {
298- if index == linear {
299- return nil // header
300- }
301- linear++
302- if index < linear+len(g.Entries) {
303- return g.Entries[index-linear]
304- }
305- linear += len(g.Entries)
306- }
307- return nil
284+ return m.rows[index].Entry
308285 }
309286
310-// entryURLAt returns the URL of the entry at the given linear index, or
311-// "" for header rows or out-of-range indices.
312287 func (m keyboardNavModel) entryURLAt(index int) string {
313288 e := m.entryAt(index)
314289 if e == nil {
@@ -317,11 +292,12 @@ func (m keyboardNavModel) entryURLAt(index int) string {
317292 return e.URL
318293 }
319294
320-// entryCount returns the total number of history entries (selectable rows).
321295 func (m keyboardNavModel) entryCount() int {
322296 n := 0
323- for _, g := range m.groups {
324- n += len(g.Entries)
297+ for i := range m.rows {
298+ if m.isSelectable(i) {
299+ n++
300+ }
325301 }
326302 return n
327303 }
M internal/ui/component/history_model_test.go +45 -0
@@ -412,6 +412,51 @@ func TestKeyboardNavModel_EntryCount(t *testing.T) {
412412 assert.Equal(t, 10, newKeyboardNavModel(makeGroups(3, 3, 4)).entryCount())
413413 }
414414
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+
415460 // =============================================================================
416461 // Search state transition tests
417462 // =============================================================================
M internal/ui/component/history_sidebar.go +4 -1318
@@ -2,15 +2,11 @@ package component
22
33 import (
44 "context"
5- "fmt"
65 "sync"
76
8- "github.com/bnema/puregotk/v4/gdk"
97 "github.com/bnema/puregotk/v4/glib"
108 "github.com/bnema/puregotk/v4/gtk"
11- "github.com/bnema/puregotk/v4/pango"
129
13- "github.com/bnema/dumber/internal/application/dto"
1410 "github.com/bnema/dumber/internal/application/port"
1511 "github.com/bnema/dumber/internal/domain/entity"
1612 "github.com/bnema/dumber/internal/logging"
@@ -61,8 +57,9 @@ type HistorySidebar struct {
6157 onClose func()
6258
6359 // Data
64- allEntries []*entity.HistoryEntry // Flat list, most-recent-first
65- groups []historyGroup // Current display groups (filtered)
60+ allEntries []*entity.HistoryEntry // Flat list, most-recent-first
61+ groups []historyGroup // Current display groups (filtered)
62+ displayRows []historyDisplayRow // Explicit rows currently rendered
6663
6764 // Paging state (browse mode only)
6865 totalLoaded int // how many entries have been fetched so far
@@ -314,7 +311,7 @@ func (hs *HistorySidebar) Reload() {
314311 hs.hasMore = hs.historyUC != nil
315312 hs.isLoading = false
316313 hs.allEntries = nil
317- hs.groups = nil
314+ hs.setDisplayGroupsLocked(nil)
318315 hs.searchResults = nil
319316 hs.searchDone = false
320317 hs.searchErr = nil
@@ -348,1317 +345,6 @@ func (hs *HistorySidebar) ClearSearch() {
348345 hs.SetSearchQuery("")
349346 }
350347
351-// =============================================================================
352-// Widget creation
353-// =============================================================================
354-
355-func (hs *HistorySidebar) createWidgets() error {
356- if err := hs.initOuterBox(); err != nil {
357- return err
358- }
359- if err := hs.initSearchBox(); err != nil {
360- return err
361- }
362- if err := hs.initListArea(); err != nil {
363- return err
364- }
365- return nil
366-}
367-
368-func (hs *HistorySidebar) initOuterBox() error {
369- hs.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
370- if hs.outerBox == nil {
371- return fmt.Errorf("history sidebar: outer box creation failed")
372- }
373- hs.outerBox.AddCssClass("history-sidebar-outer")
374- hs.outerBox.SetSizeRequest(sidebarMinWidth, -1)
375- hs.outerBox.SetHexpand(false)
376- hs.outerBox.SetVexpand(true)
377- hs.outerBox.SetVisible(false)
378- return nil
379-}
380-
381-func (hs *HistorySidebar) initSearchBox() error {
382- hs.searchBox = gtk.NewBox(gtk.OrientationHorizontalValue, 4)
383- if hs.searchBox == nil {
384- return fmt.Errorf("history sidebar: search box creation failed")
385- }
386- hs.searchBox.AddCssClass("history-sidebar-search-box")
387- hs.searchBox.SetHexpand(true)
388-
389- hs.searchEntry = gtk.NewSearchEntry()
390- if hs.searchEntry == nil {
391- return fmt.Errorf("history sidebar: search entry creation failed")
392- }
393- hs.searchEntry.AddCssClass("history-sidebar-search")
394- hs.searchEntry.SetHexpand(true)
395- placeholder := "Search history..."
396- hs.searchEntry.SetPlaceholderText(&placeholder)
397-
398- hs.searchBox.Append(&hs.searchEntry.Widget)
399- hs.outerBox.Append(&hs.searchBox.Widget)
400- return nil
401-}
402-
403-func (hs *HistorySidebar) initListArea() error {
404- hs.scrolledWin = gtk.NewScrolledWindow()
405- if hs.scrolledWin == nil {
406- return fmt.Errorf("history sidebar: scrolled window creation failed")
407- }
408- hs.scrolledWin.SetVexpand(true)
409- hs.scrolledWin.SetHexpand(true)
410- hs.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
411- hs.scrolledWin.AddCssClass("history-sidebar-groups")
412-
413- hs.listBox = gtk.NewListBox()
414- if hs.listBox == nil {
415- return fmt.Errorf("history sidebar: list box creation failed")
416- }
417- hs.listBox.AddCssClass("history-sidebar-groups")
418- hs.listBox.SetActivateOnSingleClick(true)
419- hs.listBox.SetSelectionMode(gtk.SelectionSingleValue)
420-
421- // Connect row activation (Enter or double-click)
422- rowActivatedCb := func(_ gtk.ListBox, rowPtr uintptr) {
423- row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
424- if row == nil {
425- return
426- }
427- hs.onRowActivated(row)
428- }
429- hs.retainedCallbacks = append(hs.retainedCallbacks, rowActivatedCb)
430- hs.listBox.ConnectRowActivated(&rowActivatedCb)
431-
432- hs.scrolledWin.SetChild(&hs.listBox.Widget)
433- hs.outerBox.Append(&hs.scrolledWin.Widget)
434- return nil
435-}
436-
437-// =============================================================================
438-// Data loading — background goroutine with paging
439-// =============================================================================
440-
441-func (hs *HistorySidebar) startLoadHistory() {
442- hs.mu.Lock()
443- hs.loadGen++
444- gen := hs.loadGen
445- hs.loadStarted = true
446- hs.isLoading = true
447- hs.mu.Unlock()
448-
449- // Fetch first page in a background goroutine
450- go hs.fetchPage(0, gen)
451-}
452-
453-// fetchPage fetches a page of history entries in a background goroutine
454-// and schedules the UI update on the GTK main thread.
455-func (hs *HistorySidebar) fetchPage(offset int, gen uint64) {
456- hs.mu.RLock()
457- uc := hs.historyUC
458- ctx := hs.ctx
459- hs.mu.RUnlock()
460-
461- if uc == nil || ctx == nil {
462- // No provider; show empty state
463- cb := glib.SourceFunc(func(uintptr) bool {
464- hs.mu.Lock()
465- if hs.destroyed {
466- hs.mu.Unlock()
467- return false
468- }
469- hs.loadStarted = false
470- hs.isLoading = false
471- hs.loadDone = true
472- hs.hasMore = false
473- hs.mu.Unlock()
474- hs.scheduleRebuild()
475- return false
476- })
477- glib.IdleAdd(&cb, 0)
478- return
479- }
480-
481- entries, err := uc.GetRecent(ctx, sidebarPageSize, offset)
482- if err != nil {
483- hs.logger.Error().Err(err).Int("offset", offset).Msg("failed to load history page")
484- }
485-
486- if entries == nil {
487- entries = []*entity.HistoryEntry{}
488- }
489-
490- hasMore := len(entries) >= sidebarPageSize
491-
492- hs.mu.Lock()
493-
494- // If a newer load was started since this fetch began, drop stale results.
495- // Must NOT mutate isLoading/loadStarted — they belong to the current
496- // generation set by startLoadHistory or LoadMore.
497- if gen != hs.loadGen {
498- hs.mu.Unlock()
499- return
500- }
501-
502- // If search is active, don't update browse state with stale page data
503- // and don't overwrite search results.
504- if hs.currentQuery != "" {
505- hs.isLoading = false
506- hs.loadStarted = false
507- hs.mu.Unlock()
508- return
509- }
510-
511- hs.totalLoaded = offset + len(entries)
512- hs.hasMore = hasMore
513- hs.isLoading = false
514- hs.loadStarted = false
515- hs.loadDone = true
516-
517- if offset == 0 {
518- // First page: replace all entries
519- hs.allEntries = entries
520- } else {
521- // Subsequent page: append
522- hs.allEntries = append(hs.allEntries, entries...)
523- }
524-
525- // Group for display
526- hs.groups = groupHistoryByDay(hs.allEntries)
527- hs.mu.Unlock()
528-
529- // Schedule UI rebuild on GTK main thread
530- cb := glib.SourceFunc(func(uintptr) bool {
531- hs.rebuildList()
532- return false
533- })
534- glib.IdleAdd(&cb, 0)
535-}
536-
537-// LoadMore fetches the next page and appends it to the existing entries.
538-func (hs *HistorySidebar) LoadMore() {
539- hs.mu.Lock()
540- if hs.isLoading || !hs.hasMore || hs.destroyed || hs.currentQuery != "" {
541- hs.mu.Unlock()
542- return
543- }
544- hs.isLoading = true
545- offset := hs.totalLoaded
546- gen := hs.loadGen
547- hs.mu.Unlock()
548-
549- hs.logger.Debug().Int("offset", offset).Msg("loading more history entries")
550- go hs.fetchPage(offset, gen)
551-}
552-
553-// =============================================================================
554-// Scroll-aware load-more: detects when the user reaches the bottom
555-// =============================================================================
556-
557-func (hs *HistorySidebar) setupScrollLoadMore() {
558- if hs.scrolledWin == nil {
559- return
560- }
561-
562- vadj := hs.scrolledWin.GetVadjustment()
563- if vadj == nil {
564- return
565- }
566-
567- changedCb := func(_ gtk.Adjustment) {
568- hs.mu.RLock()
569- if hs.destroyed || !hs.hasMore || hs.isLoading {
570- hs.mu.RUnlock()
571- return
572- }
573- value := vadj.GetValue()
574- upper := vadj.GetUpper()
575- pageSize := vadj.GetPageSize()
576- hs.mu.RUnlock()
577-
578- // Trigger load-more when within 200px of the bottom
579- if pageSize > 0 && value+pageSize >= upper-200.0 {
580- hs.LoadMore()
581- }
582- }
583- hs.retainedCallbacks = append(hs.retainedCallbacks, changedCb)
584- vadj.ConnectValueChanged(&changedCb)
585-}
586-
587-// =============================================================================
588-// Scroll/selection preservation
589-// =============================================================================
590-
591-// preserveScrollAndSelection saves the current scroll position and selected row
592-// URL before a rebuild. Must be called with hs.mu write lock held.
593-func (hs *HistorySidebar) preserveScrollAndSelection() {
594- hs.prevScrollValue = 0
595- hs.prevSelectedURL = ""
596-
597- if hs.scrolledWin != nil {
598- if vadj := hs.scrolledWin.GetVadjustment(); vadj != nil {
599- hs.prevScrollValue = vadj.GetValue()
600- }
601- }
602- if hs.listBox != nil {
603- if selected := hs.listBox.GetSelectedRow(); selected != nil {
604- if url := hs.entryURLAtIndex(selected.GetIndex()); url != "" {
605- hs.prevSelectedURL = url
606- }
607- }
608- }
609-}
610-
611-// restoreScrollAndSelection restores the previously saved scroll position and
612-// selection after a rebuild. Called on the GTK main thread.
613-func (hs *HistorySidebar) restoreScrollAndSelection() {
614- // Restore selection first (changes scroll position)
615- if hs.prevSelectedURL != "" {
616- hs.selectRowByURL(hs.prevSelectedURL)
617- }
618-
619- // Then restore scroll position if we have one
620- if hs.prevScrollValue > 0 && hs.scrolledWin != nil {
621- if vadj := hs.scrolledWin.GetVadjustment(); vadj != nil {
622- maxVal := vadj.GetUpper() - vadj.GetPageSize()
623- if hs.prevScrollValue > maxVal {
624- hs.prevScrollValue = maxVal
625- }
626- if hs.prevScrollValue >= 0 {
627- vadj.SetValue(hs.prevScrollValue)
628- }
629- }
630- }
631-
632- hs.prevScrollValue = 0
633- hs.prevSelectedURL = ""
634-}
635-
636-// getRowURL extracts the URL stored in a list box row.
637-func (hs *HistorySidebar) getRowURL(row *gtk.ListBoxRow) string {
638- if row == nil || !row.GetSelectable() {
639- return ""
640- }
641- child := row.GetChild()
642- if child == nil {
643- return ""
644- }
645-
646- // The child is the vertical box. Walk children to find our stored URL.
647- // We store the URL directly on the row as data.
648- // Actually, let's use a simpler approach: walk the list box to find the entry.
649- idx := row.GetIndex()
650- hs.mu.RLock()
651- defer hs.mu.RUnlock()
652-
653- return hs.entryURLAtIndex(idx)
654-}
655-
656-// entryURLAtIndex returns the URL of the history entry at the given
657-// linear list index (including group headers which return "").
658-func (hs *HistorySidebar) entryURLAtIndex(index int) string {
659- return newKeyboardNavModel(hs.groups).entryURLAt(index)
660-}
661-
662-// selectRowByURL finds and selects a row whose URL matches.
663-func (hs *HistorySidebar) selectRowByURL(url string) {
664- if url == "" || hs.listBox == nil {
665- return
666- }
667- for i := 0; ; i++ {
668- row := hs.listBox.GetRowAtIndex(i)
669- if row == nil {
670- break
671- }
672- if !row.GetSelectable() {
673- continue
674- }
675- if hs.getRowURL(row) == url {
676- hs.listBox.SelectRow(row)
677- return
678- }
679- }
680-}
681-
682-// =============================================================================
683-// Search / filtering
684-// =============================================================================
685-
686-func (hs *HistorySidebar) setupSearchHandler() {
687- if hs.searchEntry == nil {
688- return
689- }
690-
691- changedCb := func(_ gtk.SearchEntry) {
692- hs.onSearchChanged()
693- }
694- hs.retainedCallbacks = append(hs.retainedCallbacks, changedCb)
695- hs.searchEntry.ConnectSearchChanged(&changedCb)
696-}
697-
698-func (hs *HistorySidebar) onSearchChanged() {
699- hs.mu.Lock()
700- if hs.destroyed {
701- hs.mu.Unlock()
702- return
703- }
704- hs.currentQuery = hs.searchEntry.GetText()
705- hs.preserveScrollAndSelection()
706- oldTimer := hs.debounceTimer
707- hs.debounceTimer = 0
708- hs.mu.Unlock()
709-
710- if oldTimer != 0 {
711- glib.SourceRemove(oldTimer)
712- }
713-
714- filterCb := glib.SourceFunc(func(uintptr) bool {
715- hs.applyFilter()
716- return false
717- })
718- timerID := glib.TimeoutAdd(uint(sidebarSearchDebounceMs), &filterCb, 0)
719-
720- hs.mu.Lock()
721- if hs.destroyed {
722- hs.mu.Unlock()
723- if timerID != 0 {
724- glib.SourceRemove(timerID)
725- }
726- return
727- }
728- hs.debounceTimer = timerID
729- hs.mu.Unlock()
730-}
731-
732-func (hs *HistorySidebar) applyFilter() {
733- hs.mu.Lock()
734- hs.debounceTimer = 0
735- query := hs.currentQuery
736-
737- if query == "" {
738- // Empty query: use in-memory browse entries (paged getRecent).
739- // Clear search state and invalidate any in-flight search so a late
740- // search result doesn't overwrite browse state.
741- hs.searchResults = nil
742- hs.searchDone = false
743- hs.searchGen++
744- hs.groups = nil
745- if !hs.loadDone {
746- // Browse was never fully loaded (e.g., a search superseded the
747- // initial page fetch). Clear the list, show a loading indicator,
748- // and restart loading history in the background.
749- hs.mu.Unlock()
750- hs.scheduleRebuild() // Shows "Loading history…" while fetch runs
751- hs.startLoadHistory()
752- return
753- }
754- hs.groups = groupHistoryByDay(hs.allEntries)
755- hs.mu.Unlock()
756- hs.scheduleRebuild()
757- return
758- }
759-
760- // Non-empty query: use real FTS search via the provider.
761- // Cancel any stale in-flight search via generation counter.
762- hs.searchGen++
763- gen := hs.searchGen
764- hs.searchDone = false
765- hs.searchResults = nil
766- hs.groups = nil
767- hs.mu.Unlock()
768-
769- // Clear the list immediately to avoid showing stale browse results
770- // while the search is in flight.
771- hs.scheduleClearList()
772-
773- hs.doFTSearch(query, gen)
774-}
775-
776-// doFTSearch runs a history FTS search in a background goroutine and
777-// updates the display when results arrive. Stale results (from a superseded
778-// search generation) are silently dropped.
779-func (hs *HistorySidebar) doFTSearch(query string, gen uint64) {
780- hs.mu.RLock()
781- uc := hs.historyUC
782- hs.mu.RUnlock()
783-
784- if uc == nil {
785- return
786- }
787-
788- go func() {
789- out, err := uc.Search(hs.ctx, dto.HistorySearchInput{Query: query, Limit: sidebarSearchLimit})
790- var entries []*entity.HistoryEntry
791- if out != nil {
792- entries = make([]*entity.HistoryEntry, len(out.Matches))
793- for i, m := range out.Matches {
794- entries[i] = m.Entry
795- }
796- }
797- if err != nil {
798- hs.logger.Error().Err(err).Str("query", query).Msg("history FTS search failed")
799- }
800- if entries == nil {
801- entries = []*entity.HistoryEntry{}
802- }
803-
804- // Apply results on the GTK main thread with stale-result protection
805- cb := glib.SourceFunc(func(uintptr) bool {
806- if hs.applySearchResults(entries, gen, err) {
807- hs.scheduleRebuild()
808- }
809- return false
810- })
811- hs.scheduleIdle(cb)
812- }()
813-}
814-
815-// applySearchResults applies search results under the generation guard.
816-// Returns true if results were applied (non-stale), false if the generation
817-// had moved on and the results were dropped.
818-func (hs *HistorySidebar) applySearchResults(entries []*entity.HistoryEntry, gen uint64, err error) bool {
819- hs.mu.Lock()
820- defer hs.mu.Unlock()
821- if hs.destroyed || gen != hs.searchGen {
822- return false
823- }
824- hs.searchResults = entries
825- hs.searchDone = true
826- if err != nil {
827- hs.searchErr = err
828- }
829- hs.groups = groupHistoryByDay(entries)
830- return true
831-}
832-
833-func (hs *HistorySidebar) scheduleIdle(cb glib.SourceFunc) {
834- if hs != nil && hs.idleScheduler != nil {
835- hs.idleScheduler(cb)
836- return
837- }
838- glib.IdleAdd(&cb, 0)
839-}
840-
841-// scheduleClearList clears the list box on the GTK main thread.
842-func (hs *HistorySidebar) scheduleClearList() {
843- cb := glib.SourceFunc(func(uintptr) bool {
844- hs.mu.RLock()
845- destroyed := hs.destroyed
846- listBox := hs.listBox
847- hs.mu.RUnlock()
848- if destroyed || listBox == nil {
849- return false
850- }
851- listBox.RemoveAll()
852- return false
853- })
854- hs.scheduleIdle(cb)
855-}
856-
857-// scheduleRebuild schedules a list rebuild on the GTK main thread.
858-func (hs *HistorySidebar) scheduleRebuild() {
859- cb := glib.SourceFunc(func(uintptr) bool {
860- hs.rebuildList()
861- return false
862- })
863- hs.scheduleIdle(cb)
864-}
865-
866-// =============================================================================
867-// List rendering
868-// =============================================================================
869-
870-// rebuildList clears and repopulates the list box from current groups.
871-// Must be called on the GTK main thread. Preserves scroll and selection.
872-func (hs *HistorySidebar) rebuildList() {
873- hs.mu.RLock()
874- if hs.destroyed || hs.listBox == nil {
875- hs.mu.RUnlock()
876- return
877- }
878- groups := hs.groups
879- query := hs.currentQuery
880- hasSearchResults := hs.searchResults != nil
881- totalLoaded := hs.totalLoaded
882- hs.mu.RUnlock()
883-
884- // Remove all rows
885- hs.listBox.RemoveAll()
886-
887- if len(groups) == 0 {
888- if !hasSearchResults && totalLoaded == 0 {
889- // Browse has not loaded yet AND no search has completed.
890- hs.showLoadingOrEmpty()
891- return
892- }
893- // Search completed with 0 results, or browse loaded but empty (no history).
894- hs.showEmptyState(query)
895- hs.restoreScrollAndSelection()
896- return
897- }
898-
899- for _, group := range groups {
900- // Group header row
901- hs.appendGroupHeader(group.Label)
902-
903- // Entry rows
904- for _, entry := range group.Entries {
905- hs.appendEntryRow(entry)
906- }
907- }
908-
909- hs.listBox.Show()
910-
911- // Restore previous scroll position and selection
912- hs.restoreScrollAndSelection()
913-
914- // If no selection was restored and this is the first load, select first entry
915- hs.ensureAtLeastOneSelection()
916-}
917-
918-func (hs *HistorySidebar) showLoadingOrEmpty() {
919- label := gtk.NewLabel(nil)
920- if label == nil {
921- return
922- }
923- label.AddCssClass("history-sidebar-loading")
924-
925- hs.mu.RLock()
926- isLoading := hs.isLoading
927- query := hs.currentQuery
928- hs.mu.RUnlock()
929-
930- switch {
931- case isLoading && query == "":
932- label.SetText("Loading history...")
933- case query != "":
934- label.SetText(fmt.Sprintf("No results for \"%s\"", query))
935- default:
936- label.SetText("No browsing history")
937- }
938-
939- label.SetWrap(false)
940- label.SetXalign(0.0)
941-
942- row := gtk.NewListBoxRow()
943- if row == nil {
944- return
945- }
946- row.SetSelectable(false)
947- row.SetCanFocus(false)
948- row.SetActivatable(false)
949- row.SetChild(&label.Widget)
950- hs.listBox.Append(&row.Widget)
951-}
952-
953-func (hs *HistorySidebar) showEmptyState(query string) {
954- label := gtk.NewLabel(nil)
955- if label == nil {
956- return
957- }
958- label.AddCssClass("history-sidebar-empty")
959-
960- if query != "" {
961- label.SetText(fmt.Sprintf("No results for \"%s\"", query))
962- } else {
963- label.SetText("No browsing history")
964- }
965-
966- label.SetWrap(false)
967- label.SetXalign(0.0)
968-
969- row := gtk.NewListBoxRow()
970- if row == nil {
971- return
972- }
973- row.SetSelectable(false)
974- row.SetCanFocus(false)
975- row.SetActivatable(false)
976- row.SetChild(&label.Widget)
977- hs.listBox.Append(&row.Widget)
978-}
979-
980-// appendGroupHeader adds a non-selectable group header label to the list.
981-func (hs *HistorySidebar) appendGroupHeader(labelText string) {
982- label := gtk.NewLabel(&labelText)
983- if label == nil {
984- return
985- }
986- label.AddCssClass("history-sidebar-group-header")
987- label.SetXalign(0.0)
988- label.SetHexpand(true)
989-
990- row := gtk.NewListBoxRow()
991- if row == nil {
992- return
993- }
994- row.SetSelectable(false)
995- row.SetCanFocus(false)
996- row.SetActivatable(false)
997- row.SetChild(&label.Widget)
998- hs.listBox.Append(&row.Widget)
999-}
1000-
1001-// appendEntryRow adds a selectable two-line entry row to the list.
1002-func (hs *HistorySidebar) appendEntryRow(entry *entity.HistoryEntry) {
1003- // Outer vertical box for two-line layout
1004- rowBox := gtk.NewBox(gtk.OrientationVerticalValue, 1)
1005- if rowBox == nil {
1006- return
1007- }
1008- rowBox.SetHexpand(true)
1009-
1010- // Title line (first line)
1011- titleLabel := gtk.NewLabel(nil)
1012- if titleLabel == nil {
1013- return
1014- }
1015- titleLabel.AddCssClass("history-sidebar-row-title")
1016- titleLabel.SetText(safeSidebarString(entry.Title, entry.URL))
1017- titleLabel.SetXalign(0.0)
1018- titleLabel.SetHexpand(true)
1019- titleLabel.SetEllipsize(pango.EllipsizeEndValue)
1020-
1021- // Subtitle line with URL and time
1022- subBox := gtk.NewBox(gtk.OrientationHorizontalValue, 0)
1023- if subBox == nil {
1024- return
1025- }
1026- subBox.SetHexpand(true)
1027-
1028- urlLabel := gtk.NewLabel(nil)
1029- if urlLabel == nil {
1030- return
1031- }
1032- urlLabel.AddCssClass("history-sidebar-row-subtitle")
1033- urlLabel.SetText(readableURL(entry.URL))
1034- urlLabel.SetXalign(0.0)
1035- urlLabel.SetHexpand(true)
1036- urlLabel.SetEllipsize(pango.EllipsizeEndValue)
1037-
1038- timeLabel := gtk.NewLabel(nil)
1039- if timeLabel == nil {
1040- return
1041- }
1042- timeLabel.AddCssClass("history-sidebar-row-time")
1043- timeLabel.SetText(relativeTime(entry.LastVisited))
1044- timeLabel.SetXalign(1.0)
1045-
1046- subBox.Append(&urlLabel.Widget)
1047- subBox.Append(&timeLabel.Widget)
1048-
1049- rowBox.Append(&titleLabel.Widget)
1050- rowBox.Append(&subBox.Widget)
1051-
1052- // Create the list box row
1053- row := gtk.NewListBoxRow()
1054- if row == nil {
1055- return
1056- }
1057- row.AddCssClass("history-sidebar-row")
1058- row.SetSelectable(true)
1059- row.SetActivatable(true)
1060- row.SetCanFocus(true)
1061- row.SetFocusOnClick(true)
1062- row.SetChild(&rowBox.Widget)
1063-
1064- hs.listBox.Append(&row.Widget)
1065-}
1066-
1067-// ensureAtLeastOneSelection selects the first selectable row if nothing is selected.
1068-func (hs *HistorySidebar) ensureAtLeastOneSelection() {
1069- if hs.listBox.GetSelectedRow() != nil {
1070- return
1071- }
1072- for i := 0; ; i++ {
1073- row := hs.listBox.GetRowAtIndex(i)
1074- if row == nil {
1075- break
1076- }
1077- if row.GetSelectable() {
1078- hs.listBox.SelectRow(row)
1079- return
1080- }
1081- }
1082-}
1083-
1084-// =============================================================================
1085-// Keyboard navigation
1086-// =============================================================================
1087-
1088-func (hs *HistorySidebar) setupKeyboardNavigation() {
1089- if hs.outerBox == nil {
1090- return
1091- }
1092-
1093- // The ListBox already supports Up/Down arrow navigation natively.
1094- // We add a PhaseCapture key controller on the outerBox to intercept
1095- // keys before the ListBox processes them.
1096- keyController := gtk.NewEventControllerKey()
1097- if keyController == nil {
1098- return
1099- }
1100- keyController.SetPropagationPhase(gtk.PhaseCaptureValue)
1101-
1102- keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool {
1103- switch keyval {
1104- // --- Escape: clear search or close sidebar ---
1105- case uint(gdk.KEY_Escape):
1106- if hs.searchEntry != nil && hs.searchEntry.GetText() != "" {
1107- hs.searchEntry.SetText("")
1108- return true
1109- }
1110- // Close sidebar explicitly and restore focus
1111- hs.closeSidebar()
1112- return true
1113-
1114- // --- Enter variants ---
1115- case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
1116- return hs.handleEnterKey(keyval, state)
1117-
1118- // --- Delete: remove selected entry ---
1119- case uint(gdk.KEY_Delete), uint(gdk.KEY_KP_Delete):
1120- if hs.searchEntry != nil && hs.searchEntry.GetText() != "" {
1121- return false
1122- }
1123- return hs.handleDeleteKey()
1124-
1125- // --- PageUp / PageDown: scroll by page ---
1126- case uint(gdk.KEY_Page_Up):
1127- hs.scrollByPage(-1)
1128- return true
1129- case uint(gdk.KEY_Page_Down):
1130- hs.scrollByPage(1)
1131- return true
1132-
1133- // --- Home / End: jump to first/last selectable row ---
1134- case uint(gdk.KEY_Home):
1135- hs.jumpToFirstSelectable()
1136- return true
1137- case uint(gdk.KEY_End):
1138- hs.jumpToLastSelectable()
1139- return true
1140-
1141- // --- Up / Down: previous / next selectable row ---
1142- // Ctrl+Up / Ctrl+Down: previous / next day group jump ---
1143- case uint(gdk.KEY_Up):
1144- if state&gdk.ControlMaskValue != 0 {
1145- hs.jumpToPreviousDay()
1146- return true
1147- }
1148- hs.selectPreviousRow()
1149- return true
1150- case uint(gdk.KEY_Down):
1151- if state&gdk.ControlMaskValue != 0 {
1152- hs.jumpToNextDay()
1153- return true
1154- }
1155- hs.selectNextRow()
1156- return true
1157- }
1158-
1159- return false
1160- }
1161-
1162- hs.retainedCallbacks = append(hs.retainedCallbacks, keyPressedCb)
1163- keyController.ConnectKeyPressed(&keyPressedCb)
1164-
1165- hs.outerBox.AddController(&keyController.EventController)
1166-}
1167-
1168-// handleEnterKey processes Enter, Ctrl+Enter, and Shift+Enter on a selected row.
1169-// Returns true if the key was consumed.
1170-func (hs *HistorySidebar) handleEnterKey(keyval uint, state gdk.ModifierType) bool {
1171- // Determine activation mode from modifiers
1172- var action HistorySidebarKeyboardAction
1173-
1174- switch {
1175- case state&gdk.ControlMaskValue != 0:
1176- // Ctrl+Enter: navigate but keep sidebar open
1177- action = SidebarActionKeepOpenOnActivate
1178- case state&gdk.ShiftMaskValue != 0:
1179- // Shift+Enter: navigate in new pane
1180- action = SidebarActionNewPaneOnActivate
1181- default:
1182- // Plain Enter: navigate using the default activation behavior.
1183- action = SidebarActionCloseOnActivate
1184- }
1185-
1186- // Find the selected row and its URL
1187- row := hs.listBox.GetSelectedRow()
1188- if row == nil || !row.GetSelectable() {
1189- return false
1190- }
1191-
1192- hs.mu.RLock()
1193- url := hs.entryURLAtIndex(row.GetIndex())
1194- hs.mu.RUnlock()
1195- if url == "" {
1196- return false
1197- }
1198-
1199- // Schedule activation on the GTK main thread
1200- switch action {
1201- case SidebarActionKeepOpenOnActivate:
1202- hs.navigateWithoutClosing(url)
1203- case SidebarActionNewPaneOnActivate:
1204- hs.navigateToNewPane(url)
1205- default:
1206- hs.navigateToURL(url)
1207- }
1208-
1209- // Consume the key event
1210- return true
1211-}
1212-
1213-// handleDeleteKey removes the selected history entry and updates the selection.
1214-// Returns true if the key was consumed.
1215-func (hs *HistorySidebar) handleDeleteKey() bool {
1216- row := hs.listBox.GetSelectedRow()
1217- if row == nil || !row.GetSelectable() {
1218- return false
1219- }
1220- if hs.historyUC == nil {
1221- return false
1222- }
1223-
1224- idx := row.GetIndex()
1225-
1226- hs.mu.RLock()
1227- url := hs.entryURLAtIndex(idx)
1228- entryID := hs.findEntryIDByIndex(idx)
1229- nextSelectedURL := ""
1230- if nextRow := hs.findNextSelectableAfter(idx); nextRow != -1 {
1231- nextSelectedURL = hs.entryURLAtIndex(nextRow)
1232- }
1233- hs.mu.RUnlock()
1234-
1235- if url == "" || entryID <= 0 {
1236- return false
1237- }
1238-
1239- go func() {
1240- if err := hs.historyUC.Delete(hs.ctx, entryID); err != nil {
1241- hs.logger.Error().Err(err).Int64("entry_id", entryID).Msg("failed to delete history entry")
1242- return
1243- }
1244-
1245- cb := glib.SourceFunc(func(uintptr) bool {
1246- hs.mu.Lock()
1247- if hs.destroyed {
1248- hs.mu.Unlock()
1249- return false
1250- }
1251- hs.preserveScrollAndSelection()
1252- hs.prevSelectedURL = nextSelectedURL
1253- hs.searchGen++
1254- hs.removeFromAllEntries(url, entryID)
1255- hs.totalLoaded = len(hs.allEntries)
1256- hs.removeFromSearchResults(entryID)
1257- hs.rebuildLocalGroups()
1258- hs.mu.Unlock()
1259-
1260- hs.rebuildList()
1261- return false
1262- })
1263- glib.IdleAdd(&cb, 0)
1264- }()
1265-
1266- return true
1267-}
1268-
1269-// findEntryIDByIndex returns the entry ID for the linear ListBox index.
1270-// Must be called with hs.mu read lock held.
1271-func (hs *HistorySidebar) findEntryIDByIndex(index int) int64 {
1272- entry := newKeyboardNavModel(hs.groups).entryAt(index)
1273- if entry == nil {
1274- return 0
1275- }
1276- return entry.ID
1277-}
1278-
1279-// rebuildLocalGroups rebuilds hs.groups from the current allEntries and query.
1280-// Must be called with hs.mu write lock held.
1281-func (hs *HistorySidebar) rebuildLocalGroups() {
1282- if hs.currentQuery == "" {
1283- hs.groups = groupHistoryByDay(hs.allEntries)
1284- } else if hs.searchResults != nil {
1285- hs.groups = groupHistoryByDay(hs.searchResults)
1286- } else {
1287- // For search mode, the search results are handled by doFTSearch.
1288- // Removing an entry while in search mode would need a re-search.
1289- // Fall back to grouping searchResults if they exist.
1290- hs.groups = nil
1291- }
1292-}
1293-
1294-// removeFromAllEntries removes all history entries matching the given URL or ID
1295-// from hs.allEntries. Must be called with hs.mu write lock held.
1296-func (hs *HistorySidebar) removeFromAllEntries(url string, id int64) {
1297- filtered := make([]*entity.HistoryEntry, 0, len(hs.allEntries))
1298- for _, e := range hs.allEntries {
1299- if e != nil && (e.URL == url || e.ID == id) {
1300- continue
1301- }
1302- filtered = append(filtered, e)
1303- }
1304- hs.allEntries = filtered
1305-}
1306-
1307-// removeFromSearchResults removes all history entries matching the given ID
1308-// from hs.searchResults. Must be called with hs.mu write lock held.
1309-func (hs *HistorySidebar) removeFromSearchResults(id int64) {
1310- if hs.searchResults == nil {
1311- return
1312- }
1313- filtered := make([]*entity.HistoryEntry, 0, len(hs.searchResults))
1314- for _, e := range hs.searchResults {
1315- if e != nil && e.ID == id {
1316- continue
1317- }
1318- filtered = append(filtered, e)
1319- }
1320- hs.searchResults = filtered
1321-}
1322-
1323-// findNextSelectableAfter returns the ListBox index of the next selectable
1324-// row after the given index, falling back to the previous selectable row.
1325-// Must be called with hs.mu read lock held.
1326-func (hs *HistorySidebar) findNextSelectableAfter(idx int) int {
1327- model := newKeyboardNavModel(hs.groups)
1328- if next := model.nextSelectableIndex(idx, +1); next != -1 {
1329- return next
1330- }
1331- if prev := model.nextSelectableIndex(idx, -1); prev != -1 {
1332- return prev
1333- }
1334- return -1
1335-}
1336-
1337-// scrollByPage scrolls the list by one page up or down,
1338-// keeping the selection visible.
1339-func (hs *HistorySidebar) scrollByPage(direction int) {
1340- if hs.scrolledWin == nil {
1341- return
1342- }
1343- vadj := hs.scrolledWin.GetVadjustment()
1344- if vadj == nil {
1345- return
1346- }
1347- pageSize := vadj.GetPageSize()
1348- current := vadj.GetValue()
1349- newVal := current + float64(direction)*(pageSize*0.9) // 90% page for overlap
1350- if newVal < 0 {
1351- newVal = 0
1352- }
1353- upper := vadj.GetUpper() - pageSize
1354- if newVal > upper {
1355- newVal = upper
1356- }
1357- if newVal >= 0 {
1358- vadj.SetValue(newVal)
1359- }
1360-}
1361-
1362-// jumpToPreviousDay selects the first entry in the previous day group
1363-// relative to the currently selected row.
1364-func (hs *HistorySidebar) jumpToPreviousDay() {
1365- currentIdx := -1
1366- if row := hs.listBox.GetSelectedRow(); row != nil {
1367- currentIdx = row.GetIndex()
1368- }
1369-
1370- hs.mu.RLock()
1371- targetIdx := newKeyboardNavModel(hs.groups).previousDayBoundary(currentIdx)
1372- hs.mu.RUnlock()
1373- if targetIdx == -1 {
1374- hs.jumpToFirstSelectable()
1375- return
1376- }
1377- if row := hs.listBox.GetRowAtIndex(targetIdx); row != nil && row.GetSelectable() {
1378- hs.listBox.SelectRow(row)
1379- hs.scrollRowIntoView(targetIdx)
1380- return
1381- }
1382- hs.jumpToFirstSelectable()
1383-}
1384-
1385-// jumpToNextDay selects the first entry in the next day group
1386-// relative to the currently selected row.
1387-func (hs *HistorySidebar) jumpToNextDay() {
1388- currentIdx := -1
1389- if row := hs.listBox.GetSelectedRow(); row != nil {
1390- currentIdx = row.GetIndex()
1391- }
1392-
1393- hs.mu.RLock()
1394- targetIdx := newKeyboardNavModel(hs.groups).nextDayBoundary(currentIdx)
1395- hs.mu.RUnlock()
1396- if targetIdx == -1 {
1397- hs.jumpToLastSelectable()
1398- return
1399- }
1400- if row := hs.listBox.GetRowAtIndex(targetIdx); row != nil && row.GetSelectable() {
1401- hs.listBox.SelectRow(row)
1402- hs.scrollRowIntoView(targetIdx)
1403- return
1404- }
1405- hs.jumpToLastSelectable()
1406-}
1407-
1408-// scrollRowIntoView scrolls the scrolled window to ensure the row at
1409-// the given ListBox index is visible.
1410-func (hs *HistorySidebar) scrollRowIntoView(index int) {
1411- hs.ensureRowVisible(index)
1412-}
1413-
1414-// jumpToFirstSelectable selects the first selectable row in the list.
1415-func (hs *HistorySidebar) jumpToFirstSelectable() {
1416- for i := 0; ; i++ {
1417- row := hs.listBox.GetRowAtIndex(i)
1418- if row == nil {
1419- break
1420- }
1421- if row.GetSelectable() {
1422- hs.listBox.SelectRow(row)
1423- hs.ensureRowVisible(i)
1424- return
1425- }
1426- }
1427-}
1428-
1429-// jumpToLastSelectable selects the last selectable row in the list.
1430-func (hs *HistorySidebar) jumpToLastSelectable() {
1431- // Walk backwards through the rows
1432- maxIdx := 0
1433- var lastRow *gtk.ListBoxRow
1434- for i := 0; ; i++ {
1435- row := hs.listBox.GetRowAtIndex(i)
1436- if row == nil {
1437- break
1438- }
1439- maxIdx = i
1440- if row.GetSelectable() {
1441- lastRow = row
1442- }
1443- }
1444- // If we found a selectable row, try it. Otherwise fall back to last row.
1445- if lastRow != nil {
1446- hs.listBox.SelectRow(lastRow)
1447- hs.ensureRowVisible(lastRow.GetIndex())
1448- return
1449- }
1450- // Fallback: last row regardless of selectability
1451- if maxIdx > 0 {
1452- if row := hs.listBox.GetRowAtIndex(maxIdx); row != nil {
1453- hs.listBox.SelectRow(row)
1454- hs.ensureRowVisible(maxIdx)
1455- }
1456- }
1457-}
1458-
1459-// =============================================================================
1460-// Up/Down row selection (with search entry focus preserved)
1461-// =============================================================================
1462-
1463-// selectPreviousRow selects the previous selectable row (skipping headers).
1464-// Focus remains in the search entry; the ListBox selection is updated
1465-// programmatically and scrolled into view.
1466-func (hs *HistorySidebar) selectPreviousRow() {
1467- hs.selectAdjacentRow(-1)
1468-}
1469-
1470-// selectNextRow selects the next selectable row (skipping headers).
1471-// Focus remains in the search entry; the ListBox selection is updated
1472-// programmatically and scrolled into view.
1473-func (hs *HistorySidebar) selectNextRow() {
1474- hs.selectAdjacentRow(1)
1475-}
1476-
1477-// selectAdjacentRow moves selection by direction (-1 or +1) to the next
1478-// selectable row, skipping non-selectable (header) rows. If nothing is
1479-// currently selected, it selects the first (down) or last (up) selectable
1480-// row. Focus remains in the search entry.
1481-func (hs *HistorySidebar) selectAdjacentRow(direction int) {
1482- if hs.listBox == nil {
1483- return
1484- }
1485-
1486- current := -1
1487- if row := hs.listBox.GetSelectedRow(); row != nil {
1488- current = row.GetIndex()
1489- }
1490-
1491- hs.mu.RLock()
1492- model := newKeyboardNavModel(hs.groups)
1493- target := -1
1494- if current < 0 {
1495- if direction > 0 {
1496- target = model.firstSelectableIndex()
1497- } else {
1498- target = model.lastSelectableIndex()
1499- }
1500- } else {
1501- target = model.nextSelectableIndex(current, direction)
1502- }
1503- hs.mu.RUnlock()
1504-
1505- if target == -1 {
1506- return
1507- }
1508- if row := hs.listBox.GetRowAtIndex(target); row != nil && row.GetSelectable() {
1509- hs.listBox.SelectRow(row)
1510- hs.ensureRowVisible(target)
1511- }
1512-}
1513-
1514-// ensureRowVisible adjusts the scrolled window so the row at index is
1515-// visible, WITHOUT calling GrabFocus (preserving search entry focus).
1516-// The Y position is computed by summing the allocated heights of all
1517-// preceding rows.
1518-func (hs *HistorySidebar) ensureRowVisible(index int) {
1519- if hs.scrolledWin == nil || hs.listBox == nil {
1520- return
1521- }
1522- vadj := hs.scrolledWin.GetVadjustment()
1523- if vadj == nil {
1524- return
1525- }
1526- row := hs.listBox.GetRowAtIndex(index)
1527- if row == nil {
1528- return
1529- }
1530-
1531- // Sum allocated heights of all preceding rows to estimate Y position.
1532- var yPos int
1533- for i := 0; i < index; i++ {
1534- r := hs.listBox.GetRowAtIndex(i)
1535- if r == nil {
1536- continue
1537- }
1538- yPos += r.GetAllocatedHeight()
1539- }
1540-
1541- rowHeight := row.GetAllocatedHeight()
1542- if rowHeight <= 0 {
1543- return
1544- }
1545-
1546- pageSize := vadj.GetPageSize()
1547- current := vadj.GetValue()
1548- rowTop := float64(yPos)
1549- rowBottom := rowTop + float64(rowHeight)
1550-
1551- if rowTop < current {
1552- // Row is above the visible area — scroll up.
1553- vadj.SetValue(rowTop)
1554- } else if rowBottom > current+pageSize {
1555- // Row is below the visible area — scroll down.
1556- vadj.SetValue(rowBottom - pageSize)
1557- }
1558-}
1559-
1560-// =============================================================================
1561-// Row activation (Enter / click)
1562-// =============================================================================
1563-
1564-func (hs *HistorySidebar) onRowActivated(row *gtk.ListBoxRow) {
1565- if row == nil || !row.GetSelectable() {
1566- return
1567- }
1568-
1569- hs.mu.RLock()
1570- // Allow activation when browse is loaded or when search results are available.
1571- // This prevents a race where the user searches before the initial browse page
1572- // finishes loading — browse may be left unloaded, but search results should
1573- // still be activatable.
1574- hasSearchResults := hs.searchDone && hs.searchResults != nil
1575- if (!hs.loadDone && !hasSearchResults) || len(hs.groups) == 0 {
1576- hs.mu.RUnlock()
1577- return
1578- }
1579- entry := newKeyboardNavModel(hs.groups).entryAt(row.GetIndex())
1580- hs.mu.RUnlock()
1581- if entry == nil || entry.URL == "" {
1582- return
1583- }
1584-
1585- hs.navigateToURL(entry.URL)
1586-}
1587-
1588-func (hs *HistorySidebar) navigateToURL(url string) {
1589- if hs.onURL == nil || url == "" {
1590- return
1591- }
1592-
1593- navigateCb := glib.SourceFunc(func(uintptr) bool {
1594- hs.mu.RLock()
1595- destroyed := hs.destroyed
1596- hs.mu.RUnlock()
1597- if destroyed {
1598- return false
1599- }
1600- hs.onURL(hs.ctx, url)
1601- return false
1602- })
1603- glib.IdleAdd(&navigateCb, 0)
1604-}
1605-
1606-// navigateWithoutClosing navigates to the URL but does NOT close the sidebar.
1607-// Used by Ctrl+Enter activation.
1608-func (hs *HistorySidebar) navigateWithoutClosing(url string) {
1609- if hs.onNavigateKeepOpen == nil || url == "" {
1610- return
1611- }
1612- hs.doNavigateWithoutClose(url)
1613-}
1614-
1615-// doNavigateWithoutClose schedules navigation without closing the sidebar.
1616-// Uses the dedicated OnNavigateKeepOpen path so hosts can override the
1617-// default activation behavior when they need a distinct keep-open action.
1618-func (hs *HistorySidebar) doNavigateWithoutClose(url string) {
1619- navigateCb := glib.SourceFunc(func(uintptr) bool {
1620- hs.mu.RLock()
1621- destroyed := hs.destroyed
1622- hs.mu.RUnlock()
1623- if destroyed {
1624- return false
1625- }
1626- hs.onNavigateKeepOpen(hs.ctx, url)
1627- return false
1628- })
1629- glib.IdleAdd(&navigateCb, 0)
1630-}
1631-
1632-// navigateToNewPane navigates to the URL by opening it in a new pane.
1633-// The sidebar stays open. Used by Shift+Enter activation.
1634-func (hs *HistorySidebar) navigateToNewPane(url string) {
1635- if hs.onOpenInNewPane == nil || url == "" {
1636- return
1637- }
1638-
1639- navigateCb := glib.SourceFunc(func(uintptr) bool {
1640- hs.mu.RLock()
1641- destroyed := hs.destroyed
1642- hs.mu.RUnlock()
1643- if destroyed {
1644- return false
1645- }
1646- if err := hs.onOpenInNewPane(hs.ctx, url); err != nil {
1647- hs.logger.Error().Err(err).Str("url", url).Msg("history sidebar new-pane navigation failed")
1648- }
1649- return false
1650- })
1651- glib.IdleAdd(&navigateCb, 0)
1652-}
1653-
1654-// closeSidebar calls the configured OnClose callback to tell the host to
1655-// hide the sidebar and restore focus to the active content pane/webview.
1656-func (hs *HistorySidebar) closeSidebar() {
1657- if hs.onClose != nil {
1658- hs.onClose()
1659- }
1660-}
1661-
1662348 // =============================================================================
1663349 // Helpers
1664350 // =============================================================================
A internal/ui/component/history_sidebar_keyboard.go +596 -0
@@ -0,0 +1,596 @@
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(keyval, 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(keyval uint, 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(url, 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(url string, 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(url, 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(url string, id int64) {
233+ filtered := make([]*entity.HistoryEntry, 0, len(hs.allEntries))
234+ for _, e := range hs.allEntries {
235+ if e != nil && (e.URL == url || 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+ // If we found a selectable row, try it. Otherwise fall back to last row.
381+ if lastRow != nil {
382+ hs.listBox.SelectRow(lastRow)
383+ hs.ensureRowVisible(lastRow.GetIndex())
384+ return
385+ }
386+ // Fallback: last row regardless of selectability
387+ if maxIdx > 0 {
388+ if row := hs.listBox.GetRowAtIndex(maxIdx); row != nil {
389+ hs.listBox.SelectRow(row)
390+ hs.ensureRowVisible(maxIdx)
391+ }
392+ }
393+}
394+
395+// =============================================================================
396+// Up/Down row selection (with search entry focus preserved)
397+// =============================================================================
398+
399+// selectPreviousRow selects the previous selectable row (skipping headers).
400+// Focus remains in the search entry; the ListBox selection is updated
401+// programmatically and scrolled into view.
402+func (hs *HistorySidebar) selectPreviousRow() {
403+ hs.selectAdjacentRow(-1)
404+}
405+
406+// selectNextRow selects the next selectable row (skipping headers).
407+// Focus remains in the search entry; the ListBox selection is updated
408+// programmatically and scrolled into view.
409+func (hs *HistorySidebar) selectNextRow() {
410+ hs.selectAdjacentRow(1)
411+}
412+
413+// selectAdjacentRow moves selection by direction (-1 or +1) to the next
414+// selectable row, skipping non-selectable (header) rows. If nothing is
415+// currently selected, it selects the first (down) or last (up) selectable
416+// row. Focus remains in the search entry.
417+func (hs *HistorySidebar) selectAdjacentRow(direction int) {
418+ if hs.listBox == nil {
419+ return
420+ }
421+
422+ current := -1
423+ if row := hs.listBox.GetSelectedRow(); row != nil {
424+ current = row.GetIndex()
425+ }
426+
427+ hs.mu.RLock()
428+ model := newKeyboardNavModelFromRows(hs.displayRows)
429+ target := -1
430+ if current < 0 {
431+ if direction > 0 {
432+ target = model.firstSelectableIndex()
433+ } else {
434+ target = model.lastSelectableIndex()
435+ }
436+ } else {
437+ target = model.nextSelectableIndex(current, direction)
438+ }
439+ hs.mu.RUnlock()
440+
441+ if target == -1 {
442+ return
443+ }
444+ if row := hs.listBox.GetRowAtIndex(target); row != nil && row.GetSelectable() {
445+ hs.listBox.SelectRow(row)
446+ hs.ensureRowVisible(target)
447+ }
448+}
449+
450+// ensureRowVisible adjusts the scrolled window so the row at index is
451+// visible, WITHOUT calling GrabFocus (preserving search entry focus).
452+// The Y position is computed by summing the allocated heights of all
453+// preceding rows.
454+func (hs *HistorySidebar) ensureRowVisible(index int) {
455+ if hs.scrolledWin == nil || hs.listBox == nil {
456+ return
457+ }
458+ vadj := hs.scrolledWin.GetVadjustment()
459+ if vadj == nil {
460+ return
461+ }
462+ row := hs.listBox.GetRowAtIndex(index)
463+ if row == nil {
464+ return
465+ }
466+
467+ // Sum allocated heights of all preceding rows to estimate Y position.
468+ var yPos int
469+ for i := 0; i < index; i++ {
470+ r := hs.listBox.GetRowAtIndex(i)
471+ if r == nil {
472+ continue
473+ }
474+ yPos += r.GetAllocatedHeight()
475+ }
476+
477+ rowHeight := row.GetAllocatedHeight()
478+ if rowHeight <= 0 {
479+ return
480+ }
481+
482+ pageSize := vadj.GetPageSize()
483+ current := vadj.GetValue()
484+ rowTop := float64(yPos)
485+ rowBottom := rowTop + float64(rowHeight)
486+
487+ if rowTop < current {
488+ // Row is above the visible area — scroll up.
489+ vadj.SetValue(rowTop)
490+ } else if rowBottom > current+pageSize {
491+ // Row is below the visible area — scroll down.
492+ vadj.SetValue(rowBottom - pageSize)
493+ }
494+}
495+
496+// =============================================================================
497+// Row activation (Enter / click)
498+// =============================================================================
499+
500+func (hs *HistorySidebar) onRowActivated(row *gtk.ListBoxRow) {
501+ if row == nil || !row.GetSelectable() {
502+ return
503+ }
504+
505+ hs.mu.RLock()
506+ // Allow activation when browse is loaded or when search results are available.
507+ // This prevents a race where the user searches before the initial browse page
508+ // finishes loading — browse may be left unloaded, but search results should
509+ // still be activatable.
510+ hasSearchResults := hs.searchDone && hs.searchResults != nil
511+ if (!hs.loadDone && !hasSearchResults) || len(hs.groups) == 0 {
512+ hs.mu.RUnlock()
513+ return
514+ }
515+ entry := newKeyboardNavModelFromRows(hs.displayRows).entryAt(row.GetIndex())
516+ hs.mu.RUnlock()
517+ if entry == nil || entry.URL == "" {
518+ return
519+ }
520+
521+ hs.navigateToURL(entry.URL)
522+}
523+
524+func (hs *HistorySidebar) navigateToURL(url string) {
525+ if hs.onURL == nil || url == "" {
526+ return
527+ }
528+
529+ navigateCb := glib.SourceFunc(func(uintptr) bool {
530+ hs.mu.RLock()
531+ destroyed := hs.destroyed
532+ hs.mu.RUnlock()
533+ if destroyed {
534+ return false
535+ }
536+ hs.onURL(hs.ctx, url)
537+ return false
538+ })
539+ hs.scheduleIdle(navigateCb)
540+}
541+
542+// navigateWithoutClosing navigates to the URL but does NOT close the sidebar.
543+// Used by Ctrl+Enter activation.
544+func (hs *HistorySidebar) navigateWithoutClosing(url string) {
545+ if hs.onNavigateKeepOpen == nil || url == "" {
546+ return
547+ }
548+ hs.doNavigateWithoutClose(url)
549+}
550+
551+// doNavigateWithoutClose schedules navigation without closing the sidebar.
552+// Uses the dedicated OnNavigateKeepOpen path so hosts can override the
553+// default activation behavior when they need a distinct keep-open action.
554+func (hs *HistorySidebar) doNavigateWithoutClose(url string) {
555+ navigateCb := glib.SourceFunc(func(uintptr) bool {
556+ hs.mu.RLock()
557+ destroyed := hs.destroyed
558+ hs.mu.RUnlock()
559+ if destroyed {
560+ return false
561+ }
562+ hs.onNavigateKeepOpen(hs.ctx, url)
563+ return false
564+ })
565+ hs.scheduleIdle(navigateCb)
566+}
567+
568+// navigateToNewPane navigates to the URL by opening it in a new pane.
569+// The sidebar stays open. Used by Shift+Enter activation.
570+func (hs *HistorySidebar) navigateToNewPane(url string) {
571+ if hs.onOpenInNewPane == nil || url == "" {
572+ return
573+ }
574+
575+ navigateCb := glib.SourceFunc(func(uintptr) bool {
576+ hs.mu.RLock()
577+ destroyed := hs.destroyed
578+ hs.mu.RUnlock()
579+ if destroyed {
580+ return false
581+ }
582+ if err := hs.onOpenInNewPane(hs.ctx, url); err != nil {
583+ hs.logger.Error().Err(err).Str("url", url).Msg("history sidebar new-pane navigation failed")
584+ }
585+ return false
586+ })
587+ hs.scheduleIdle(navigateCb)
588+}
589+
590+// closeSidebar calls the configured OnClose callback to tell the host to
591+// hide the sidebar and restore focus to the active content pane/webview.
592+func (hs *HistorySidebar) closeSidebar() {
593+ if hs.onClose != nil {
594+ hs.onClose()
595+ }
596+}
A internal/ui/component/history_sidebar_loading_search.go +445 -0
@@ -0,0 +1,445 @@
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+ glib.IdleAdd(&cb, 0)
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+ glib.IdleAdd(&cb, 0)
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+ if hs.prevScrollValue >= 0 {
201+ vadj.SetValue(hs.prevScrollValue)
202+ }
203+ }
204+ }
205+
206+ hs.prevScrollValue = 0
207+ hs.prevSelectedURL = ""
208+}
209+
210+// getRowURL extracts the URL stored in a list box row.
211+func (hs *HistorySidebar) getRowURL(row *gtk.ListBoxRow) string {
212+ if row == nil || !row.GetSelectable() {
213+ return ""
214+ }
215+ child := row.GetChild()
216+ if child == nil {
217+ return ""
218+ }
219+
220+ // The child is the vertical box. Walk children to find our stored URL.
221+ // We store the URL directly on the row as data.
222+ // Actually, let's use a simpler approach: walk the list box to find the entry.
223+ idx := row.GetIndex()
224+ hs.mu.RLock()
225+ defer hs.mu.RUnlock()
226+
227+ return hs.entryURLAtIndex(idx)
228+}
229+
230+// entryURLAtIndex returns the URL of the history entry at the given
231+// linear list index (including group headers which return "").
232+func (hs *HistorySidebar) entryURLAtIndex(index int) string {
233+ return newKeyboardNavModelFromRows(hs.displayRows).entryURLAt(index)
234+}
235+
236+// selectRowByURL finds and selects a row whose URL matches.
237+func (hs *HistorySidebar) selectRowByURL(url string) {
238+ if url == "" || hs.listBox == nil {
239+ return
240+ }
241+ for i := 0; ; i++ {
242+ row := hs.listBox.GetRowAtIndex(i)
243+ if row == nil {
244+ break
245+ }
246+ if !row.GetSelectable() {
247+ continue
248+ }
249+ if hs.getRowURL(row) == url {
250+ hs.listBox.SelectRow(row)
251+ return
252+ }
253+ }
254+}
255+
256+// =============================================================================
257+// Search / filtering
258+// =============================================================================
259+
260+func (hs *HistorySidebar) setupSearchHandler() {
261+ if hs.searchEntry == nil {
262+ return
263+ }
264+
265+ changedCb := func(_ gtk.SearchEntry) {
266+ hs.onSearchChanged()
267+ }
268+ hs.retainedCallbacks = append(hs.retainedCallbacks, changedCb)
269+ hs.searchEntry.ConnectSearchChanged(&changedCb)
270+}
271+
272+func (hs *HistorySidebar) onSearchChanged() {
273+ hs.mu.Lock()
274+ if hs.destroyed {
275+ hs.mu.Unlock()
276+ return
277+ }
278+ hs.currentQuery = hs.searchEntry.GetText()
279+ hs.preserveScrollAndSelection()
280+ oldTimer := hs.debounceTimer
281+ hs.debounceTimer = 0
282+ hs.mu.Unlock()
283+
284+ if oldTimer != 0 {
285+ glib.SourceRemove(oldTimer)
286+ }
287+
288+ filterCb := glib.SourceFunc(func(uintptr) bool {
289+ hs.applyFilter()
290+ return false
291+ })
292+ timerID := glib.TimeoutAdd(uint(sidebarSearchDebounceMs), &filterCb, 0)
293+
294+ hs.mu.Lock()
295+ if hs.destroyed {
296+ hs.mu.Unlock()
297+ if timerID != 0 {
298+ glib.SourceRemove(timerID)
299+ }
300+ return
301+ }
302+ hs.debounceTimer = timerID
303+ hs.mu.Unlock()
304+}
305+
306+func (hs *HistorySidebar) applyFilter() {
307+ hs.mu.Lock()
308+ hs.debounceTimer = 0
309+ query := hs.currentQuery
310+
311+ if query == "" {
312+ // Empty query: use in-memory browse entries (paged getRecent).
313+ // Clear search state and invalidate any in-flight search so a late
314+ // search result doesn't overwrite browse state.
315+ hs.searchResults = nil
316+ hs.searchDone = false
317+ hs.searchGen++
318+ hs.setDisplayGroupsLocked(nil)
319+ if !hs.loadDone {
320+ // Browse was never fully loaded (e.g., a search superseded the
321+ // initial page fetch). Clear the list, show a loading indicator,
322+ // and restart loading history in the background.
323+ hs.mu.Unlock()
324+ hs.scheduleRebuild() // Shows "Loading history…" while fetch runs
325+ hs.startLoadHistory()
326+ return
327+ }
328+ hs.setDisplayGroupsLocked(groupHistoryByDay(hs.allEntries))
329+ hs.mu.Unlock()
330+ hs.scheduleRebuild()
331+ return
332+ }
333+
334+ // Non-empty query: use real FTS search via the provider.
335+ // Cancel any stale in-flight search via generation counter.
336+ hs.searchGen++
337+ gen := hs.searchGen
338+ hs.searchDone = false
339+ hs.searchResults = nil
340+ hs.setDisplayGroupsLocked(nil)
341+ hs.mu.Unlock()
342+
343+ // Clear the list immediately to avoid showing stale browse results
344+ // while the search is in flight.
345+ hs.scheduleClearList()
346+
347+ hs.doFTSearch(query, gen)
348+}
349+
350+// doFTSearch runs a history FTS search in a background goroutine and
351+// updates the display when results arrive. Stale results (from a superseded
352+// search generation) are silently dropped.
353+func (hs *HistorySidebar) doFTSearch(query string, gen uint64) {
354+ hs.mu.RLock()
355+ uc := hs.historyUC
356+ hs.mu.RUnlock()
357+
358+ if uc == nil {
359+ return
360+ }
361+
362+ go func() {
363+ out, err := uc.Search(hs.ctx, dto.HistorySearchInput{Query: query, Limit: sidebarSearchLimit})
364+ var entries []*entity.HistoryEntry
365+ if out != nil {
366+ entries = make([]*entity.HistoryEntry, len(out.Matches))
367+ for i, m := range out.Matches {
368+ entries[i] = m.Entry
369+ }
370+ }
371+ if err != nil {
372+ hs.logger.Error().Err(err).Str("query", query).Msg("history FTS search failed")
373+ }
374+ if entries == nil {
375+ entries = []*entity.HistoryEntry{}
376+ }
377+
378+ // Apply results on the GTK main thread with stale-result protection
379+ cb := glib.SourceFunc(func(uintptr) bool {
380+ if hs.applySearchResults(entries, gen, err) {
381+ hs.scheduleRebuild()
382+ }
383+ return false
384+ })
385+ hs.scheduleIdle(cb)
386+ }()
387+}
388+
389+// applySearchResults applies search results under the generation guard.
390+// Returns true if results were applied (non-stale), false if the generation
391+// had moved on and the results were dropped.
392+func (hs *HistorySidebar) applySearchResults(entries []*entity.HistoryEntry, gen uint64, err error) bool {
393+ hs.mu.Lock()
394+ defer hs.mu.Unlock()
395+ if hs.destroyed || gen != hs.searchGen {
396+ return false
397+ }
398+ hs.searchResults = entries
399+ hs.searchDone = true
400+ if err != nil {
401+ hs.searchErr = err
402+ }
403+ hs.setDisplayGroupsLocked(groupHistoryByDay(entries))
404+ return true
405+}
406+
407+func (hs *HistorySidebar) scheduleIdle(cb glib.SourceFunc) {
408+ if hs != nil && hs.idleScheduler != nil {
409+ hs.idleScheduler(cb)
410+ return
411+ }
412+ glib.IdleAdd(&cb, 0)
413+}
414+
415+// scheduleClearList clears the list box on the GTK main thread.
416+func (hs *HistorySidebar) scheduleClearList() {
417+ cb := glib.SourceFunc(func(uintptr) bool {
418+ hs.mu.RLock()
419+ destroyed := hs.destroyed
420+ listBox := hs.listBox
421+ hs.mu.RUnlock()
422+ if destroyed || listBox == nil {
423+ return false
424+ }
425+ listBox.RemoveAll()
426+ return false
427+ })
428+ hs.scheduleIdle(cb)
429+}
430+
431+// scheduleRebuild schedules a list rebuild on the GTK main thread.
432+func (hs *HistorySidebar) scheduleRebuild() {
433+ cb := glib.SourceFunc(func(uintptr) bool {
434+ hs.rebuildList()
435+ return false
436+ })
437+ hs.scheduleIdle(cb)
438+}
439+
440+// setDisplayGroupsLocked updates grouped history and the explicit display-row
441+// model. Caller must hold hs.mu for writing.
442+func (hs *HistorySidebar) setDisplayGroupsLocked(groups []historyGroup) {
443+ hs.groups = groups
444+ hs.displayRows = buildHistoryDisplayRows(groups)
445+}
A internal/ui/component/history_sidebar_rendering.go +230 -0
@@ -0,0 +1,230 @@
1+package component
2+
3+import (
4+ "fmt"
5+
6+ "github.com/bnema/puregotk/v4/gtk"
7+ "github.com/bnema/puregotk/v4/pango"
8+
9+ "github.com/bnema/dumber/internal/domain/entity"
10+)
11+
12+// =============================================================================
13+// List rendering
14+// =============================================================================
15+
16+// rebuildList clears and repopulates the list box from current groups.
17+// Must be called on the GTK main thread. Preserves scroll and selection.
18+func (hs *HistorySidebar) rebuildList() {
19+ hs.mu.RLock()
20+ if hs.destroyed || hs.listBox == nil {
21+ hs.mu.RUnlock()
22+ return
23+ }
24+ rows := hs.displayRows
25+ query := hs.currentQuery
26+ hasSearchResults := hs.searchResults != nil
27+ totalLoaded := hs.totalLoaded
28+ hs.mu.RUnlock()
29+
30+ // Remove all rows
31+ hs.listBox.RemoveAll()
32+
33+ if len(rows) == 0 {
34+ if !hasSearchResults && totalLoaded == 0 {
35+ // Browse has not loaded yet AND no search has completed.
36+ hs.showLoadingOrEmpty()
37+ return
38+ }
39+ // Search completed with 0 results, or browse loaded but empty (no history).
40+ hs.showEmptyState(query)
41+ hs.restoreScrollAndSelection()
42+ return
43+ }
44+
45+ for _, row := range rows {
46+ switch row.Kind {
47+ case historyDisplayRowHeader:
48+ hs.appendGroupHeader(row.Label)
49+ case historyDisplayRowEntry:
50+ hs.appendEntryRow(row.Entry)
51+ }
52+ }
53+
54+ hs.listBox.Show()
55+
56+ // Restore previous scroll position and selection
57+ hs.restoreScrollAndSelection()
58+
59+ // If no selection was restored and this is the first load, select first entry
60+ hs.ensureAtLeastOneSelection()
61+}
62+
63+func (hs *HistorySidebar) showLoadingOrEmpty() {
64+ label := gtk.NewLabel(nil)
65+ if label == nil {
66+ return
67+ }
68+ label.AddCssClass("history-sidebar-loading")
69+
70+ hs.mu.RLock()
71+ isLoading := hs.isLoading
72+ query := hs.currentQuery
73+ hs.mu.RUnlock()
74+
75+ switch {
76+ case isLoading && query == "":
77+ label.SetText("Loading history...")
78+ case query != "":
79+ label.SetText(fmt.Sprintf("No results for \"%s\"", 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 (hs *HistorySidebar) showEmptyState(query string) {
99+ label := gtk.NewLabel(nil)
100+ if label == nil {
101+ return
102+ }
103+ label.AddCssClass("history-sidebar-empty")
104+
105+ if query != "" {
106+ label.SetText(fmt.Sprintf("No results for \"%s\"", query))
107+ } else {
108+ label.SetText("No browsing history")
109+ }
110+
111+ label.SetWrap(false)
112+ label.SetXalign(0.0)
113+
114+ row := gtk.NewListBoxRow()
115+ if row == nil {
116+ return
117+ }
118+ row.SetSelectable(false)
119+ row.SetCanFocus(false)
120+ row.SetActivatable(false)
121+ row.SetChild(&label.Widget)
122+ hs.listBox.Append(&row.Widget)
123+}
124+
125+// appendGroupHeader adds a non-selectable group header label to the list.
126+func (hs *HistorySidebar) appendGroupHeader(labelText string) {
127+ label := gtk.NewLabel(&labelText)
128+ if label == nil {
129+ return
130+ }
131+ label.AddCssClass("history-sidebar-group-header")
132+ label.SetXalign(0.0)
133+ label.SetHexpand(true)
134+
135+ row := gtk.NewListBoxRow()
136+ if row == nil {
137+ return
138+ }
139+ row.SetSelectable(false)
140+ row.SetCanFocus(false)
141+ row.SetActivatable(false)
142+ row.SetChild(&label.Widget)
143+ hs.listBox.Append(&row.Widget)
144+}
145+
146+// appendEntryRow adds a selectable two-line entry row to the list.
147+func (hs *HistorySidebar) appendEntryRow(entry *entity.HistoryEntry) {
148+ if entry == nil {
149+ return
150+ }
151+ // Outer vertical box for two-line layout
152+ rowBox := gtk.NewBox(gtk.OrientationVerticalValue, 1)
153+ if rowBox == nil {
154+ return
155+ }
156+ rowBox.SetHexpand(true)
157+
158+ // Title line (first line)
159+ titleLabel := gtk.NewLabel(nil)
160+ if titleLabel == nil {
161+ return
162+ }
163+ titleLabel.AddCssClass("history-sidebar-row-title")
164+ titleLabel.SetText(safeSidebarString(entry.Title, entry.URL))
165+ titleLabel.SetXalign(0.0)
166+ titleLabel.SetHexpand(true)
167+ titleLabel.SetEllipsize(pango.EllipsizeEndValue)
168+
169+ // Subtitle line with URL and time
170+ subBox := gtk.NewBox(gtk.OrientationHorizontalValue, 0)
171+ if subBox == nil {
172+ return
173+ }
174+ subBox.SetHexpand(true)
175+
176+ urlLabel := gtk.NewLabel(nil)
177+ if urlLabel == nil {
178+ return
179+ }
180+ urlLabel.AddCssClass("history-sidebar-row-subtitle")
181+ urlLabel.SetText(readableURL(entry.URL))
182+ urlLabel.SetXalign(0.0)
183+ urlLabel.SetHexpand(true)
184+ urlLabel.SetEllipsize(pango.EllipsizeEndValue)
185+
186+ timeLabel := gtk.NewLabel(nil)
187+ if timeLabel == nil {
188+ return
189+ }
190+ timeLabel.AddCssClass("history-sidebar-row-time")
191+ timeLabel.SetText(relativeTime(entry.LastVisited))
192+ timeLabel.SetXalign(1.0)
193+
194+ subBox.Append(&urlLabel.Widget)
195+ subBox.Append(&timeLabel.Widget)
196+
197+ rowBox.Append(&titleLabel.Widget)
198+ rowBox.Append(&subBox.Widget)
199+
200+ // Create the list box row
201+ row := gtk.NewListBoxRow()
202+ if row == nil {
203+ return
204+ }
205+ row.AddCssClass("history-sidebar-row")
206+ row.SetSelectable(true)
207+ row.SetActivatable(true)
208+ row.SetCanFocus(true)
209+ row.SetFocusOnClick(true)
210+ row.SetChild(&rowBox.Widget)
211+
212+ hs.listBox.Append(&row.Widget)
213+}
214+
215+// ensureAtLeastOneSelection selects the first selectable row if nothing is selected.
216+func (hs *HistorySidebar) ensureAtLeastOneSelection() {
217+ if hs.listBox.GetSelectedRow() != nil {
218+ return
219+ }
220+ for i := 0; ; i++ {
221+ row := hs.listBox.GetRowAtIndex(i)
222+ if row == nil {
223+ break
224+ }
225+ if row.GetSelectable() {
226+ hs.listBox.SelectRow(row)
227+ return
228+ }
229+ }
230+}
M internal/ui/component/history_sidebar_search_test.go +36 -0
@@ -118,6 +118,42 @@ func TestApplySearchResults_EmptyResultsApplied(t *testing.T) {
118118 assert.Nil(t, hs.groups, "empty entries should produce nil groups")
119119 }
120120
121+func TestApplyDeletedEntryLocked_RecomputesBrowseState(t *testing.T) {
122+ now := time.Now()
123+ keepA := &entity.HistoryEntry{ID: 1, URL: "https://keep-a.com", Title: "Keep A", LastVisited: now}
124+ deleteMe := &entity.HistoryEntry{ID: 2, URL: "https://delete-me.com", Title: "Delete Me", LastVisited: now.Add(-time.Minute)}
125+ keepB := &entity.HistoryEntry{ID: 3, URL: "https://keep-b.com", Title: "Keep B", LastVisited: now.Add(-2 * time.Minute)}
126+
127+ hs := newTestSidebarSearchHarness()
128+ hs.allEntries = []*entity.HistoryEntry{keepA, deleteMe, keepB}
129+ hs.totalLoaded = len(hs.allEntries)
130+ hs.loadGen = 7
131+ hs.isLoading = true
132+ hs.loadStarted = true
133+ hs.searchResults = []*entity.HistoryEntry{deleteMe, keepB}
134+ hs.setDisplayGroupsLocked(groupHistoryByDay(hs.allEntries))
135+
136+ hs.mu.Lock()
137+ hs.applyDeletedEntryLocked(deleteMe.URL, deleteMe.ID, keepB.URL)
138+ hs.mu.Unlock()
139+
140+ assert.Equal(t, uint64(8), hs.loadGen, "deletes must invalidate in-flight browse loads")
141+ assert.False(t, hs.isLoading, "delete should clear stale loading state")
142+ assert.False(t, hs.loadStarted, "delete should clear stale loadStarted state")
143+ assert.Equal(t, 2, hs.totalLoaded, "browse pagination offset must track remaining loaded entries")
144+ assert.Equal(t, keepB.URL, hs.prevSelectedURL)
145+ assert.Len(t, hs.allEntries, 2)
146+ assert.Equal(t, []string{keepA.URL, keepB.URL}, []string{hs.allEntries[0].URL, hs.allEntries[1].URL})
147+ assert.Len(t, hs.searchResults, 1)
148+ assert.Equal(t, keepB.URL, hs.searchResults[0].URL)
149+ require.Len(t, hs.groups, 1)
150+ require.Len(t, hs.groups[0].Entries, 2)
151+ require.Len(t, hs.displayRows, 3, "one header plus two remaining entries")
152+ assert.Equal(t, historyDisplayRowHeader, hs.displayRows[0].Kind)
153+ assert.Equal(t, historyDisplayRowEntry, hs.displayRows[1].Kind)
154+ assert.Equal(t, historyDisplayRowEntry, hs.displayRows[2].Kind)
155+}
156+
121157 // =============================================================================
122158 // Async search seam: controllable history port fake
123159 // =============================================================================
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 (Enter or double-click)
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+}
M internal/ui/window/main_window.go +0 -90
@@ -197,96 +197,6 @@ func (mw *MainWindow) ContentArea() *gtk.Box {
197197 return mw.mainContentBox
198198 }
199199
200-// SidebarWidthConfig defines the initial/recommended width range for the sidebar.
201-type SidebarWidthConfig struct {
202- // WidthPx is the preferred sidebar width.
203- WidthPx int
204- // MinPx is the minimum clamped width (default 280).
205- MinPx int
206- // MaxPx is the maximum clamping bound (default 380).
207- MaxPx int
208-}
209-
210-// SidebarDefaultWidth returns a sensible default width configuration:
211-// preferred 320px, clamped to [280, 380].
212-func SidebarDefaultWidth() SidebarWidthConfig {
213- return SidebarWidthConfig{
214- WidthPx: 320,
215- MinPx: 280,
216- MaxPx: 380,
217- }
218-}
219-
220-// SidebarBox returns the sidebar container widget for embedding sidebar
221-// components. The sidebar box is hidden by default.
222-func (mw *MainWindow) SidebarBox() *gtk.Box {
223- return mw.sidebarBox
224-}
225-
226-// SetSidebarWidth sets the sidebar box width to widthPx, clamped to the
227-// config's [MinPx, MaxPx] bounds. Using the zero-value SidebarWidthConfig{}
228-// sets sensible defaults (320px clamped to [280, 380]).
229-func (mw *MainWindow) SetSidebarWidth(cfg SidebarWidthConfig) {
230- if mw.sidebarBox == nil {
231- return
232- }
233- defaults := SidebarDefaultWidth()
234- if cfg.MinPx == 0 {
235- cfg.MinPx = defaults.MinPx
236- }
237- if cfg.MaxPx == 0 {
238- cfg.MaxPx = defaults.MaxPx
239- }
240- if cfg.WidthPx == 0 {
241- cfg.WidthPx = defaults.WidthPx
242- }
243- clamped := cfg.WidthPx
244- if clamped < cfg.MinPx {
245- clamped = cfg.MinPx
246- }
247- if clamped > cfg.MaxPx {
248- clamped = cfg.MaxPx
249- }
250- mw.sidebarBox.SetSizeRequest(clamped, -1)
251- mw.logger.Debug().Int("sidebar_width", clamped).Msg("sidebar width set")
252-}
253-
254-// SetSidebarVisible shows or hides the sidebar pane.
255-func (mw *MainWindow) SetSidebarVisible(visible bool) {
256- if mw.sidebarBox == nil {
257- return
258- }
259- mw.sidebarBox.SetVisible(visible)
260- mw.logger.Debug().Bool("sidebar_visible", visible).Msg("sidebar visibility changed")
261-}
262-
263-// IsSidebarVisible returns whether the sidebar pane is currently visible.
264-func (mw *MainWindow) IsSidebarVisible() bool {
265- if mw.sidebarBox == nil {
266- return false
267- }
268- return mw.sidebarBox.GetVisible()
269-}
270-
271-// SetSidebarWidget replaces the current sidebar content widget.
272-func (mw *MainWindow) SetSidebarWidget(widget *gtk.Widget) {
273- if mw.sidebarBox == nil {
274- return
275- }
276- // Remove existing children
277- for {
278- child := mw.sidebarBox.GetFirstChild()
279- if child == nil {
280- break
281- }
282- mw.sidebarBox.Remove(child)
283- }
284- if widget != nil {
285- widget.SetVisible(true)
286- mw.sidebarBox.Append(widget)
287- }
288-}
289-
290200 // SetContent replaces the current content widget in the main content area
291201 // (the vertical box that holds workspace tab content).
292202 func (mw *MainWindow) SetContent(widget *gtk.Widget) {
A internal/ui/window/main_window_sidebar.go +89 -0
@@ -0,0 +1,89 @@
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+ clamped := cfg.WidthPx
45+ if clamped < cfg.MinPx {
46+ clamped = cfg.MinPx
47+ }
48+ if clamped > cfg.MaxPx {
49+ clamped = cfg.MaxPx
50+ }
51+ mw.sidebarBox.SetSizeRequest(clamped, -1)
52+ mw.logger.Debug().Int("sidebar_width", clamped).Msg("sidebar width set")
53+}
54+
55+// SetSidebarVisible shows or hides the sidebar pane.
56+func (mw *MainWindow) SetSidebarVisible(visible bool) {
57+ if mw.sidebarBox == nil {
58+ return
59+ }
60+ mw.sidebarBox.SetVisible(visible)
61+ mw.logger.Debug().Bool("sidebar_visible", visible).Msg("sidebar visibility changed")
62+}
63+
64+// IsSidebarVisible returns whether the sidebar pane is currently visible.
65+func (mw *MainWindow) IsSidebarVisible() bool {
66+ if mw.sidebarBox == nil {
67+ return false
68+ }
69+ return mw.sidebarBox.GetVisible()
70+}
71+
72+// SetSidebarWidget replaces the current sidebar content widget.
73+func (mw *MainWindow) SetSidebarWidget(widget *gtk.Widget) {
74+ if mw.sidebarBox == nil {
75+ return
76+ }
77+ // Remove existing children
78+ for {
79+ child := mw.sidebarBox.GetFirstChild()
80+ if child == nil {
81+ break
82+ }
83+ mw.sidebarBox.Remove(child)
84+ }
85+ if widget != nil {
86+ widget.SetVisible(true)
87+ mw.sidebarBox.Append(widget)
88+ }
89+}

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