browser_window_history_sidebar_test.go RawHistoryFolder
1package ui
2
3import (
4 "context"
5 "reflect"
6 "sync"
7 "testing"
8 "unsafe"
9
10 "github.com/bnema/dumber/internal/application/dto"
11 "github.com/bnema/dumber/internal/application/usecase"
12 "github.com/bnema/dumber/internal/domain/entity"
13 "github.com/bnema/dumber/internal/infrastructure/config"
14 "github.com/bnema/dumber/internal/shared/syncdispatch"
15 "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"
19 "github.com/bnema/dumber/internal/ui/input"
20 "github.com/bnema/dumber/internal/ui/window"
21 "github.com/bnema/puregotk/v4/glib"
22 "github.com/stretchr/testify/assert"
23 "github.com/stretchr/testify/require"
24)
25
26// =============================================================================
27// History sidebar integration tests
28// =============================================================================
29
30// TestHistorySidebarConfig_OnNavigateNavigatesActivePaneAndKeepsSidebar verifies
31// that the default OnNavigate callback targets the owning browser window's
32// active pane and keeps the sidebar visible.
33func TestHistorySidebarConfig_OnNavigateNavigatesActivePaneAndKeepsSidebar(t *testing.T) {
34 ctx := t.Context()
35
36 // Build two browser windows with independent tabs and panes.
37 tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
38 tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
39
40 firstTabs := entity.NewTabList()
41 firstTabs.Add(tab1)
42 firstTabs.SetActive(tab1.ID)
43 first := &browserWindow{id: "window-1", tabs: firstTabs}
44
45 secondTabs := entity.NewTabList()
46 secondTabs.Add(tab2)
47 secondTabs.SetActive(tab2.ID)
48 second := &browserWindow{id: "window-2", tabs: secondTabs}
49
50 // Create recording webviews, one per pane.
51 recordingWv1 := &recordingWebView{id: 1}
52 recordingWv2 := &recordingWebView{id: 2}
53
54 contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
55 contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), recordingWv1)
56 contentCoord.RegisterPopupWebView(entity.PaneID("pane-2"), recordingWv2)
57
58 navCoord := coordinator.NewNavigationCoordinator(ctx, nil, contentCoord)
59
60 app := &App{
61 tabs: entity.NewTabList(),
62 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
63 lastFocusedWindowID: first.id,
64 contentCoord: contentCoord,
65 navCoord: navCoord,
66 workspaceViews: map[entity.TabID]*component.WorkspaceView{
67 tab1.ID: {},
68 tab2.ID: {},
69 },
70 }
71 app.tabs.Add(tab1)
72 app.tabs.Add(tab2)
73
74 second.mainWindow = &window.MainWindow{}
75 second.historySidebar = &component.HistorySidebar{}
76 second.sidebarVisible = true
77
78 cfg := app.buildHistorySidebarConfig(second)
79 navigateURL := "https://example.com"
80 err := cfg.OnNavigate(ctx, navigateURL)
81 require.NoError(t, err, "OnNavigate should succeed")
82
83 // The second window's webview must have received the navigation.
84 assert.True(t, recordingWv2.loadURICalled, "second window webview should receive navigation")
85 assert.Equal(t, navigateURL, recordingWv2.loadURILastURI)
86 assert.True(t, second.sidebarVisible, "default history activation should keep the sidebar visible")
87
88 // The first window's webview must NOT have been touched (stale-focus guard).
89 assert.False(t, recordingWv1.loadURICalled, "first window webview must not receive navigation from second window callback")
90}
91
92// TestHistorySidebarConfig_OnNavigateKeepOpenNavigatesWithoutClosing verifies
93// that the OnNavigateKeepOpen callback (Ctrl+Enter) navigates the owning window's
94// active pane and keeps the sidebar visible.
95func TestHistorySidebarConfig_OnNavigateKeepOpenNavigatesWithoutClosing(t *testing.T) {
96 ctx := t.Context()
97
98 tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
99 bwTabs := entity.NewTabList()
100 bwTabs.Add(tab)
101 bwTabs.SetActive(tab.ID)
102
103 bw := &browserWindow{
104 id: "window-1",
105 tabs: bwTabs,
106 mainWindow: &window.MainWindow{},
107 historySidebar: &component.HistorySidebar{},
108 sidebarVisible: true,
109 }
110
111 contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
112 recordingWv := &recordingWebView{id: 1}
113 contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), recordingWv)
114 navCoord := coordinator.NewNavigationCoordinator(ctx, nil, contentCoord)
115
116 app := &App{
117 tabs: entity.NewTabList(),
118 browserWindows: map[string]*browserWindow{bw.id: bw},
119 contentCoord: contentCoord,
120 navCoord: navCoord,
121 workspaceViews: map[entity.TabID]*component.WorkspaceView{
122 tab.ID: {},
123 },
124 }
125 app.tabs.Add(tab)
126
127 cfg := app.buildHistorySidebarConfig(bw)
128 navigateURL := "https://keep-open.com"
129 err := cfg.OnNavigateKeepOpen(ctx, navigateURL)
130 require.NoError(t, err)
131 assert.True(t, recordingWv.loadURICalled)
132 assert.Equal(t, navigateURL, recordingWv.loadURILastURI, "Ctrl+Enter navigation should go to the URL")
133 assert.True(t, bw.sidebarVisible, "Ctrl+Enter navigation should keep the sidebar visible")
134}
135
136// TestHistorySidebar_OwnershipOnMultiWindowNavigation verifies that when
137// multiple browser windows have history sidebars, navigation targets the
138// correct owning window's active pane. This tests the stale-focus scenario
139// where a different window is globally focused.
140func TestHistorySidebar_OwnershipOnMultiWindowNavigation(t *testing.T) {
141 ctx := t.Context()
142
143 // Two windows, each with their own tab and pane.
144 tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
145 tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
146
147 firstTabs := entity.NewTabList()
148 firstTabs.Add(tab1)
149 firstTabs.SetActive(tab1.ID)
150 first := &browserWindow{id: "window-1", tabs: firstTabs}
151
152 secondTabs := entity.NewTabList()
153 secondTabs.Add(tab2)
154 secondTabs.SetActive(tab2.ID)
155 second := &browserWindow{id: "window-2", tabs: secondTabs}
156
157 recordingWv1 := &recordingWebView{id: 1}
158 recordingWv2 := &recordingWebView{id: 2}
159
160 contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
161 contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), recordingWv1)
162 contentCoord.RegisterPopupWebView(entity.PaneID("pane-2"), recordingWv2)
163
164 navCoord := coordinator.NewNavigationCoordinator(ctx, nil, contentCoord)
165
166 app := &App{
167 tabs: entity.NewTabList(),
168 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
169 lastFocusedWindowID: first.id, // STALE: first is globally focused
170 contentCoord: contentCoord,
171 navCoord: navCoord,
172 workspaceViews: map[entity.TabID]*component.WorkspaceView{
173 tab1.ID: {},
174 tab2.ID: {},
175 },
176 }
177 app.tabs.Add(tab1)
178 app.tabs.Add(tab2)
179
180 // Navigation from the SECOND window should target pane-2 even though
181 // the first window is globally focused (stale focus).
182 err := app.navigateFromBrowserWindow(ctx, second, "https://second-window.com")
183 require.NoError(t, err)
184
185 // Second window's webview must receive the navigation.
186 assert.True(t, recordingWv2.loadURICalled, "second window webview should receive navigation")
187 assert.Equal(t, "https://second-window.com", recordingWv2.loadURILastURI)
188
189 // First window's webview must NOT have been touched.
190 assert.False(t, recordingWv1.loadURICalled, "first window webview should NOT receive navigation when second was targeted")
191}
192
193// =============================================================================
194// History sidebar toggle state tests
195// =============================================================================
196
197// TestBrowserWindow_HistorySidebarToggle_NilIsNoOp verifies that when
198// browserWindow.historySidebar is nil, toggleHistorySidebar is a safe
199// no-op and sidebarVisible stays / remains false.
200func TestBrowserWindow_HistorySidebarToggle_NilIsNoOp(t *testing.T) {
201 t.Parallel()
202
203 bw := &browserWindow{id: "test-window", sidebarVisible: false}
204 require.Nil(t, bw.historySidebar, "historySidebar must be nil for this test")
205
206 // Should not panic even though historySidebar is nil.
207 bw.toggleHistorySidebar()
208 assert.False(t, bw.sidebarVisible, "sidebarVisible must remain false when historySidebar is nil")
209
210 // Calling again also safe.
211 bw.toggleHistorySidebar()
212 assert.False(t, bw.sidebarVisible, "sidebarVisible must remain false on second toggle")
213}
214
215// TestBrowserWindow_HistorySidebarToggle_FlipsSidebarVisible verifies that
216// toggleHistorySidebar correctly flips sidebarVisible when the sidebar
217// has been set. Uses a zero-value HistorySidebar (all nil-checked methods
218// are safe to call).
219func TestBrowserWindow_HistorySidebarToggle_FlipsSidebarVisible(t *testing.T) {
220 t.Parallel()
221
222 bw := &browserWindow{
223 id: "test-window",
224 mainWindow: &window.MainWindow{},
225 historySidebar: &component.HistorySidebar{},
226 sidebarVisible: false,
227 }
228
229 bw.toggleHistorySidebar()
230 assert.True(t, bw.sidebarVisible, "sidebarVisible must be true after first toggle")
231
232 bw.toggleHistorySidebar()
233 assert.False(t, bw.sidebarVisible, "sidebarVisible must be false after second toggle")
234
235 bw.toggleHistorySidebar()
236 assert.True(t, bw.sidebarVisible, "sidebarVisible must be true after third toggle")
237}
238
239// TestBrowserWindow_HistorySidebarShowHide_TransitionsSidebarVisible
240// verifies that showHistorySidebar and hideHistorySidebar independently
241// set sidebarVisible to true and false respectively.
242func TestBrowserWindow_HistorySidebarShowHide_TransitionsSidebarVisible(t *testing.T) {
243 t.Parallel()
244
245 bw := &browserWindow{
246 id: "test-window",
247 mainWindow: &window.MainWindow{},
248 historySidebar: &component.HistorySidebar{},
249 sidebarVisible: false,
250 }
251
252 // Show sets visible
253 bw.showHistorySidebar()
254 assert.True(t, bw.sidebarVisible, "sidebarVisible must be true after show")
255
256 // Hide clears visible
257 bw.hideHistorySidebar()
258 assert.False(t, bw.sidebarVisible, "sidebarVisible must be false after hide")
259
260 // Redundant hide is idempotent
261 bw.hideHistorySidebar()
262 assert.False(t, bw.sidebarVisible, "sidebarVisible must remain false after redundant hide")
263
264 // Show again
265 bw.showHistorySidebar()
266 assert.True(t, bw.sidebarVisible, "sidebarVisible must be true after second show")
267}
268
269// TestBrowserWindow_HistorySidebarShowHide_NilSidebarIsNoOp verifies that
270// show/hide do not panic when historySidebar is nil.
271func TestBrowserWindow_HistorySidebarShowHide_NilSidebarIsNoOp(t *testing.T) {
272 t.Parallel()
273
274 bw := &browserWindow{id: "test-window"}
275 require.Nil(t, bw.historySidebar)
276
277 // Should not panic even though historySidebar is nil.
278 bw.showHistorySidebar()
279 assert.False(t, bw.sidebarVisible, "sidebarVisible must remain false when nil")
280
281 bw.hideHistorySidebar()
282 assert.False(t, bw.sidebarVisible, "sidebarVisible must remain false when nil")
283}
284
285// =============================================================================
286// App wiring: toggle handler uses lastFocusedBrowserWindow
287// =============================================================================
288
289// TestApp_HistorySidebarToggleHandlerUsesLastFocusedWindow verifies that
290// the toggle handler wired in App.wireKeyboardActions picks the
291// lastFocusedBrowserWindow and calls toggleHistorySidebar on it.
292func TestApp_HistorySidebarToggleHandlerUsesLastFocusedWindow(t *testing.T) {
293 // Two windows, only the focused one has a history sidebar.
294 focusedBW := &browserWindow{
295 id: "focused",
296 mainWindow: &window.MainWindow{},
297 historySidebar: &component.HistorySidebar{},
298 sidebarVisible: false,
299 }
300 otherBW := &browserWindow{
301 id: "other",
302 mainWindow: &window.MainWindow{},
303 }
304
305 app := &App{
306 browserWindows: map[string]*browserWindow{
307 focusedBW.id: focusedBW,
308 otherBW.id: otherBW,
309 },
310 lastFocusedWindowID: focusedBW.id,
311 }
312
313 // Simulate the toggle handler that wireKeyboardActions registers:
314 // a.kbDispatcher.SetOnToggleHistorySidebar(func(ctx context.Context) error {
315 // bw := a.lastFocusedBrowserWindow()
316 // ...
317 // })
318 bw := app.lastFocusedBrowserWindow()
319 require.NotNil(t, bw, "lastFocusedBrowserWindow must not be nil")
320 require.Equal(t, focusedBW.id, bw.id, "must return the focused window")
321 require.NotNil(t, bw.historySidebar, "focused window must have a history sidebar")
322
323 // Toggle on the focused window.
324 bw.toggleHistorySidebar()
325 assert.True(t, bw.sidebarVisible, "sidebar on focused window must become visible")
326
327 // The other window must remain untouched.
328 assert.False(t, otherBW.sidebarVisible, "other window sidebar must remain invisible")
329
330 // Toggle again: focused window sidebar hides.
331 bw.toggleHistorySidebar()
332 assert.False(t, bw.sidebarVisible, "sidebar on focused window must hide on second toggle")
333}
334
335// TestApp_HistorySidebarToggleHandler_NilFocusedWindowIsNoOp verifies
336// that the toggle handler is safe when lastFocusedBrowserWindow returns nil.
337func TestApp_HistorySidebarToggleHandler_NilFocusedWindowIsNoOp(t *testing.T) {
338 app := &App{
339 browserWindows: make(map[string]*browserWindow),
340 lastFocusedWindowID: "missing",
341 }
342
343 // The handler should return early without error when bw is nil.
344 bw := app.lastFocusedBrowserWindow()
345 require.Nil(t, bw, "lastFocusedBrowserWindow should return nil for missing window")
346}
347
348// =============================================================================
349// History sidebar config callbacks: focus restoration on close
350// =============================================================================
351
352// TestHistorySidebarConfig_OnCloseHidesSidebar verifies that the OnClose
353// callback hides the sidebar for the owning browser window.
354func TestHistorySidebarConfig_OnCloseHidesSidebar(t *testing.T) {
355 tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
356 bwTabs := entity.NewTabList()
357 bwTabs.Add(tab)
358 bwTabs.SetActive(tab.ID)
359
360 bw := &browserWindow{
361 id: "window-1",
362 tabs: bwTabs,
363 mainWindow: &window.MainWindow{},
364 historySidebar: &component.HistorySidebar{},
365 sidebarVisible: true,
366 }
367
368 app := &App{
369 tabs: entity.NewTabList(),
370 browserWindows: map[string]*browserWindow{bw.id: bw},
371 workspaceViews: map[entity.TabID]*component.WorkspaceView{tab.ID: {}},
372 }
373 app.tabs.Add(tab)
374
375 cfg := app.buildHistorySidebarConfig(bw)
376 cfg.OnClose()
377
378 assert.False(t, bw.sidebarVisible, "sidebar must be hidden by OnClose")
379}
380
381// =============================================================================
382// Dispatcher-backed Ctrl+H integration test
383// =============================================================================
384
385// TestApp_HistorySidebar_ToggleThroughKeyboardDispatcher wires the real toggle
386// handler from App.wireKeyboardActions through the KeyboardDispatcher and
387// dispatches ActionToggleHistorySystemView, asserting the focused browser
388// window's sidebar visibility toggles.
389func TestApp_HistorySidebar_ToggleThroughKeyboardDispatcher(t *testing.T) {
390 ctx := t.Context()
391
392 focusedBW := &browserWindow{
393 id: "focused",
394 mainWindow: &window.MainWindow{},
395 historySidebar: &component.HistorySidebar{},
396 sidebarVisible: false,
397 }
398 otherBW := &browserWindow{
399 id: "other",
400 mainWindow: &window.MainWindow{},
401 }
402
403 app := &App{
404 browserWindows: map[string]*browserWindow{
405 focusedBW.id: focusedBW,
406 otherBW.id: otherBW,
407 },
408 lastFocusedWindowID: focusedBW.id,
409 }
410
411 // Create a KeyboardDispatcher and wire the production toggle handler.
412 kbDispatcher := dispatcher.NewKeyboardDispatcher(
413 ctx,
414 &coordinator.WorkspaceCoordinator{},
415 &coordinator.NavigationCoordinator{},
416 nil, nil,
417 dispatcher.KeyboardActions{},
418 func(context.Context) entity.PaneID { return "" },
419 )
420
421 kbDispatcher.SetOnToggleHistorySidebar(app.toggleHistorySidebarAction)
422
423 // First dispatch: toggle ON.
424 err := kbDispatcher.Dispatch(ctx, input.ActionToggleHistorySystemView)
425 require.NoError(t, err)
426 assert.True(t, focusedBW.sidebarVisible, "focused window sidebar must be visible after toggle")
427 assert.False(t, otherBW.sidebarVisible, "other window sidebar must remain invisible")
428
429 // Second dispatch: toggle OFF.
430 err = kbDispatcher.Dispatch(ctx, input.ActionToggleHistorySystemView)
431 require.NoError(t, err)
432 assert.False(t, focusedBW.sidebarVisible, "focused window sidebar must be hidden after second toggle")
433}
434
435// TestApp_HistorySidebar_ToggleThroughDispatcher_UnavailableReturnsError verifies that
436// when the focused window has no history sidebar, Ctrl+H returns a clean error.
437func TestApp_HistorySidebar_ToggleThroughDispatcher_UnavailableReturnsError(t *testing.T) {
438 ctx := t.Context()
439
440 bw := &browserWindow{
441 id: "no-sidebar",
442 mainWindow: &window.MainWindow{},
443 // historySidebar is nil
444 }
445
446 app := &App{
447 browserWindows: map[string]*browserWindow{bw.id: bw},
448 lastFocusedWindowID: bw.id,
449 }
450
451 kbDispatcher := dispatcher.NewKeyboardDispatcher(
452 ctx,
453 &coordinator.WorkspaceCoordinator{},
454 &coordinator.NavigationCoordinator{},
455 nil, nil,
456 dispatcher.KeyboardActions{},
457 func(context.Context) entity.PaneID { return "" },
458 )
459
460 kbDispatcher.SetOnToggleHistorySidebar(app.toggleHistorySidebarAction)
461
462 err := kbDispatcher.Dispatch(ctx, input.ActionToggleHistorySystemView)
463 require.Error(t, err)
464 require.ErrorContains(t, err, "history sidebar unavailable")
465 assert.False(t, bw.sidebarVisible, "sidebar must remain invisible when not wired")
466}
467
468// TestApp_HistorySidebar_ToggleThroughDispatcher_NilFocusedReturnsError verifies
469// that the toggle handler returns a clean error when there is no focused window.
470func TestApp_HistorySidebar_ToggleThroughDispatcher_NilFocusedReturnsError(t *testing.T) {
471 ctx := t.Context()
472
473 app := &App{
474 browserWindows: make(map[string]*browserWindow),
475 lastFocusedWindowID: "missing",
476 }
477
478 kbDispatcher := dispatcher.NewKeyboardDispatcher(
479 ctx,
480 &coordinator.WorkspaceCoordinator{},
481 &coordinator.NavigationCoordinator{},
482 nil, nil,
483 dispatcher.KeyboardActions{},
484 func(context.Context) entity.PaneID { return "" },
485 )
486
487 kbDispatcher.SetOnToggleHistorySidebar(app.toggleHistorySidebarAction)
488
489 err := kbDispatcher.Dispatch(ctx, input.ActionToggleHistorySystemView)
490 require.Error(t, err)
491 require.ErrorContains(t, err, "history sidebar unavailable")
492}
493
494// =============================================================================
495// buildHistorySidebarConfig callback seam tests
496// =============================================================================
497
498// TestApp_HistorySidebarConfig_NavigateCallback verifies that the OnNavigate
499// callback from buildHistorySidebarConfig navigates the owning browser window's
500// active pane to the given URL.
501func TestApp_HistorySidebarConfig_NavigateCallbackNavigates(t *testing.T) {
502 ctx := t.Context()
503
504 paneID := entity.PaneID("pane-1")
505 tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(paneID))
506 bwTabs := entity.NewTabList()
507 bwTabs.Add(tab)
508 bwTabs.SetActive(tab.ID)
509
510 bw := &browserWindow{
511 id: "window-1",
512 tabs: bwTabs,
513 mainWindow: &window.MainWindow{},
514 historySidebar: &component.HistorySidebar{},
515 sidebarVisible: true,
516 }
517
518 recordingWv := &recordingWebView{id: 1}
519 contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
520 contentCoord.RegisterPopupWebView(paneID, recordingWv)
521 navCoord := coordinator.NewNavigationCoordinator(ctx, nil, contentCoord)
522
523 app := &App{
524 tabs: entity.NewTabList(),
525 browserWindows: map[string]*browserWindow{bw.id: bw},
526 contentCoord: contentCoord,
527 navCoord: navCoord,
528 workspaceViews: map[entity.TabID]*component.WorkspaceView{
529 tab.ID: {},
530 },
531 }
532 app.tabs.Add(tab)
533
534 // Build the config using the extracted seam.
535 cfg := app.buildHistorySidebarConfig(bw)
536 require.NotNil(t, cfg.OnNavigate, "OnNavigate callback must be non-nil")
537
538 // Invoke the OnNavigate callback.
539 navigateURL := "https://navigated.com"
540 err := cfg.OnNavigate(ctx, navigateURL)
541 require.NoError(t, err)
542
543 // Verify the navigation reached the correct webview.
544 assert.True(t, recordingWv.loadURICalled, "webview must receive navigation")
545 assert.Equal(t, navigateURL, recordingWv.loadURILastURI)
546 assert.True(t, bw.sidebarVisible, "default history navigation should keep the sidebar open")
547}
548
549func TestApp_NavigateHistorySidebarSelection_KeepsSidebarVisible(t *testing.T) {
550 ctx := t.Context()
551
552 paneID := entity.PaneID("pane-1")
553 tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(paneID))
554 bwTabs := entity.NewTabList()
555 bwTabs.Add(tab)
556 bwTabs.SetActive(tab.ID)
557
558 bw := &browserWindow{
559 id: "window-1",
560 tabs: bwTabs,
561 mainWindow: &window.MainWindow{},
562 historySidebar: &component.HistorySidebar{},
563 sidebarVisible: true,
564 }
565
566 recordingWv := &recordingWebView{id: 1}
567 contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
568 contentCoord.RegisterPopupWebView(paneID, recordingWv)
569 navCoord := coordinator.NewNavigationCoordinator(ctx, nil, contentCoord)
570
571 app := &App{
572 tabs: entity.NewTabList(),
573 browserWindows: map[string]*browserWindow{bw.id: bw},
574 contentCoord: contentCoord,
575 navCoord: navCoord,
576 workspaceViews: map[entity.TabID]*component.WorkspaceView{
577 tab.ID: {},
578 },
579 }
580 app.tabs.Add(tab)
581
582 err := app.navigateHistorySidebarSelection(ctx, bw, "https://open.com")
583 require.NoError(t, err)
584 assert.True(t, recordingWv.loadURICalled)
585 assert.Equal(t, "https://open.com", recordingWv.loadURILastURI)
586 assert.True(t, bw.sidebarVisible, "history selection navigation should not hide the sidebar")
587}
588
589// TestApp_HistorySidebarConfig_NavigateCallbackOwnership verifies that
590// OnNavigate targets the callback's owning window, not the globally focused
591// window, when they differ.
592func TestApp_HistorySidebarConfig_NavigateCallbackOwnership(t *testing.T) {
593 ctx := t.Context()
594
595 tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
596 tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
597
598 firstTabs := entity.NewTabList()
599 firstTabs.Add(tab1)
600 firstTabs.SetActive(tab1.ID)
601 first := &browserWindow{id: "window-1", tabs: firstTabs}
602
603 secondTabs := entity.NewTabList()
604 secondTabs.Add(tab2)
605 secondTabs.SetActive(tab2.ID)
606 second := &browserWindow{id: "window-2", tabs: secondTabs}
607
608 recordingWv1 := &recordingWebView{id: 1}
609 recordingWv2 := &recordingWebView{id: 2}
610
611 contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
612 contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), recordingWv1)
613 contentCoord.RegisterPopupWebView(entity.PaneID("pane-2"), recordingWv2)
614 navCoord := coordinator.NewNavigationCoordinator(ctx, nil, contentCoord)
615
616 app := &App{
617 tabs: entity.NewTabList(),
618 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
619 lastFocusedWindowID: first.id, // STALE: first is globally focused
620 contentCoord: contentCoord,
621 navCoord: navCoord,
622 workspaceViews: map[entity.TabID]*component.WorkspaceView{
623 tab1.ID: {},
624 tab2.ID: {},
625 },
626 }
627 app.tabs.Add(tab1)
628 app.tabs.Add(tab2)
629
630 // Build config for the SECOND window, even though first is globally focused.
631 cfg := app.buildHistorySidebarConfig(second)
632
633 // Invoke OnNavigate — should navigate through second window's pane-2.
634 err := cfg.OnNavigate(ctx, "https://ownership.com")
635 require.NoError(t, err)
636
637 // Second window's webview must receive navigation.
638 assert.True(t, recordingWv2.loadURICalled, "second window webview must receive navigation")
639 assert.Equal(t, "https://ownership.com", recordingWv2.loadURILastURI)
640
641 // First window's webview must NOT be touched.
642 assert.False(t, recordingWv1.loadURICalled, "first window webview must not receive navigation")
643}
644
645// TestApp_HistorySidebarConfig_KeepOpenCallback verifies that
646// OnNavigateKeepOpen navigates the owning window without hiding the sidebar.
647func TestApp_HistorySidebarConfig_KeepOpenCallback(t *testing.T) {
648 ctx := t.Context()
649
650 paneID := entity.PaneID("pane-1")
651 tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(paneID))
652 bwTabs := entity.NewTabList()
653 bwTabs.Add(tab)
654 bwTabs.SetActive(tab.ID)
655
656 bw := &browserWindow{
657 id: "window-1",
658 tabs: bwTabs,
659 mainWindow: &window.MainWindow{},
660 historySidebar: &component.HistorySidebar{},
661 sidebarVisible: true,
662 }
663
664 recordingWv := &recordingWebView{id: 1}
665 contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
666 contentCoord.RegisterPopupWebView(paneID, recordingWv)
667 navCoord := coordinator.NewNavigationCoordinator(ctx, nil, contentCoord)
668
669 app := &App{
670 tabs: entity.NewTabList(),
671 browserWindows: map[string]*browserWindow{bw.id: bw},
672 contentCoord: contentCoord,
673 navCoord: navCoord,
674 workspaceViews: map[entity.TabID]*component.WorkspaceView{
675 tab.ID: {},
676 },
677 }
678 app.tabs.Add(tab)
679
680 cfg := app.buildHistorySidebarConfig(bw)
681
682 // OnNavigateKeepOpen navigates but does NOT close the sidebar.
683 navigateURL := "https://keep-open.com"
684 err := cfg.OnNavigateKeepOpen(ctx, navigateURL)
685 require.NoError(t, err)
686
687 assert.True(t, recordingWv.loadURICalled, "webview must receive navigation")
688 assert.Equal(t, navigateURL, recordingWv.loadURILastURI)
689
690 // Sidebar must remain visible (keep-open contract).
691 assert.True(t, bw.sidebarVisible, "sidebar must stay visible after keep-open navigation")
692}
693
694// TestApp_HistorySidebarConfig_OpenInNewPaneCallback verifies that
695// OnOpenInNewPane activates the owning browser window and creates a split
696// with the target URL.
697func TestApp_HistorySidebarConfig_OpenInNewPaneCallback(t *testing.T) {
698 ctx := t.Context()
699
700 paneID := entity.PaneID("pane-1")
701 tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(paneID))
702 bwTabs := entity.NewTabList()
703 bwTabs.Add(tab)
704 bwTabs.SetActive(tab.ID)
705
706 ws := entity.NewWorkspace("ws-1", entity.NewPane(paneID))
707 ws.ActivePaneID = paneID
708 tab.Workspace = ws
709
710 bw := &browserWindow{id: "window-1", tabs: bwTabs}
711
712 panesUC := usecase.NewManagePanesUseCase(func() string { return "pane-2" }, nil)
713 wsCoord := coordinator.NewWorkspaceCoordinator(ctx, coordinator.WorkspaceCoordinatorConfig{
714 PanesUC: panesUC,
715 GetActiveWS: func() (*entity.Workspace, *component.WorkspaceView) {
716 return ws, nil
717 },
718 })
719
720 app := &App{
721 tabs: entity.NewTabList(),
722 browserWindows: map[string]*browserWindow{bw.id: bw},
723 wsCoord: wsCoord,
724 workspaceViews: map[entity.TabID]*component.WorkspaceView{
725 tab.ID: {},
726 },
727 }
728 app.tabs.Add(tab)
729
730 cfg := app.buildHistorySidebarConfig(bw)
731
732 // OnOpenInNewPane should activate the owning window and split with URL.
733 splitURL := "https://shift-enter.com"
734 err := cfg.OnOpenInNewPane(ctx, splitURL)
735 require.NoError(t, err)
736
737 // After split, workspace should have 2 panes.
738 require.Equal(t, 2, ws.PaneCount(), "workspace should have 2 panes after split")
739
740 // The new pane should have the split URL.
741 allPanes := ws.AllPanes()
742 var newPane *entity.Pane
743 for _, p := range allPanes {
744 if p != nil && p.ID != paneID {
745 newPane = p
746 break
747 }
748 }
749 require.NotNil(t, newPane, "new pane must exist after split")
750 assert.Equal(t, splitURL, newPane.URI, "new pane must have the split URL")
751}
752
753// TestApp_HistorySidebarConfig_CloseCallback verifies that OnClose hides the
754// sidebar for the owning browser window and restores focus to the active pane.
755func TestApp_HistorySidebarConfig_CloseCallback(t *testing.T) {
756 paneID := entity.PaneID("pane-1")
757 tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(paneID))
758 bwTabs := entity.NewTabList()
759 bwTabs.Add(tab)
760 bwTabs.SetActive(tab.ID)
761
762 ws := entity.NewWorkspace("ws-1", entity.NewPane(paneID))
763 ws.ActivePaneID = paneID
764 tab.Workspace = ws
765
766 bw := &browserWindow{
767 id: "window-1",
768 tabs: bwTabs,
769 mainWindow: &window.MainWindow{},
770 historySidebar: &component.HistorySidebar{},
771 sidebarVisible: true,
772 }
773
774 // Create a minimal workspace view.
775 wsView := &component.WorkspaceView{}
776 // We set up the app so that hideAndRestoreFocusForBrowserWindow
777 // can find the wsView and call FocusPane on it.
778 // Since FocusPane is a method on WorkspaceView that requires GTK,
779 // we verify the state changes that happen before FocusPane:
780 // sidebarVisible must be toggled to false.
781
782 app := &App{
783 tabs: entity.NewTabList(),
784 browserWindows: map[string]*browserWindow{bw.id: bw},
785 workspaceViews: map[entity.TabID]*component.WorkspaceView{
786 tab.ID: wsView,
787 },
788 }
789 cfg := app.buildHistorySidebarConfig(bw)
790
791 // OnClose hides the sidebar.
792 cfg.OnClose()
793
794 assert.False(t, bw.sidebarVisible, "sidebar must be hidden after close")
795}
796
797// TestApp_HistorySidebarConfig_CloseWithNoSidebarIsSafe verifies that
798// OnClose (hideAndRestoreFocusForBrowserWindow) is safe when the browser
799// window has no sidebar or is nil.
800func TestApp_HistorySidebarConfig_CloseWithNoSidebarIsSafe(t *testing.T) {
801 bw := &browserWindow{
802 id: "no-sidebar",
803 mainWindow: &window.MainWindow{},
804 // historySidebar is nil
805 }
806
807 app := &App{
808 tabs: entity.NewTabList(),
809 browserWindows: map[string]*browserWindow{bw.id: bw},
810 }
811
812 cfg := app.buildHistorySidebarConfig(bw)
813
814 // Should not panic even with nil sidebar.
815 require.NotPanics(t, func() { cfg.OnClose() })
816}
817
818// TestApp_HideAndRestoreFocusForBrowserWindow_HidesSidebar verifies that
819// hideAndRestoreFocusForBrowserWindow hides the sidebar.
820func TestApp_HideAndRestoreFocusForBrowserWindow_HidesSidebar(t *testing.T) {
821 paneID := entity.PaneID("pane-1")
822 tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(paneID))
823 bwTabs := entity.NewTabList()
824 bwTabs.Add(tab)
825 bwTabs.SetActive(tab.ID)
826
827 ws := entity.NewWorkspace("ws-1", entity.NewPane(paneID))
828 ws.ActivePaneID = paneID
829 tab.Workspace = ws
830
831 bw := &browserWindow{
832 id: "window-1",
833 tabs: bwTabs,
834 mainWindow: &window.MainWindow{},
835 historySidebar: &component.HistorySidebar{},
836 sidebarVisible: true,
837 }
838
839 wsView := &component.WorkspaceView{}
840
841 app := &App{
842 tabs: entity.NewTabList(),
843 browserWindows: map[string]*browserWindow{bw.id: bw},
844 workspaceViews: map[entity.TabID]*component.WorkspaceView{
845 tab.ID: wsView,
846 },
847 }
848 app.tabs.Add(tab)
849
850 // Call hideAndRestoreFocusForBrowserWindow directly.
851 app.hideAndRestoreFocusForBrowserWindow(bw)
852
853 // Sidebar must be hidden.
854 assert.False(t, bw.sidebarVisible, "sidebar must be hidden")
855}
856
857// TestApp_HideAndRestoreFocusForBrowserWindow_NilBWIsSafe verifies that
858// hideAndRestoreFocusForBrowserWindow handles nil browser window safely.
859func TestApp_HideAndRestoreFocusForBrowserWindow_NilBWIsSafe(t *testing.T) {
860 app := &App{}
861 require.NotPanics(t, func() { app.hideAndRestoreFocusForBrowserWindow(nil) })
862}
863
864// =============================================================================
865// Sidebar width config tests
866// =============================================================================
867
868func TestHistorySidebarWidthConfig_ConfigValue(t *testing.T) {
869 cfg := historySidebarWidthConfig(350)
870 assert.Equal(t, 350, cfg.WidthPx, "should apply config-backed width of 350px")
871 assert.Equal(t, 280, cfg.MinPx, "should keep default min clamp")
872 assert.Equal(t, 380, cfg.MaxPx, "should keep default max clamp")
873}
874
875func TestHistorySidebarWidthConfig_DefaultValue(t *testing.T) {
876 cfg := historySidebarWidthConfig(0)
877 assert.Equal(t, 320, cfg.WidthPx, "should use default width of 320px when config is 0")
878 assert.Equal(t, 280, cfg.MinPx, "should keep default min clamp")
879 assert.Equal(t, 380, cfg.MaxPx, "should keep default max clamp")
880}
881
882// TestBrowserWindow_ApplySidebarWidthConfig_NilMainWindowIsSafe verifies
883// that applySidebarWidthConfig handles nil mainWindow without panic.
884func TestBrowserWindow_ApplySidebarWidthConfig_NilMainWindowIsSafe(t *testing.T) {
885 bw := &browserWindow{mainWindow: nil}
886 app := appWithRuntimeConfigForTest(&config.Config{SidebarWidth: 300})
887 require.NotPanics(t, func() { bw.applySidebarWidthConfig(app) })
888}
889
890// TestBrowserWindow_ApplySidebarWidthConfig_NilDepsIsSafe verifies
891// that applySidebarWidthConfig handles nil deps without panic.
892func TestBrowserWindow_ApplySidebarWidthConfig_NilDepsIsSafe(t *testing.T) {
893 mw := &window.MainWindow{}
894 bw := &browserWindow{mainWindow: mw}
895 app := &App{deps: nil}
896 require.NotPanics(t, func() { bw.applySidebarWidthConfig(app) })
897}
898
899type recordingHistorySidebarReloader struct {
900 calls int
901 reasons []string
902}
903
904func (r *recordingHistorySidebarReloader) RequestReloadIfVisible(reason string) {
905 r.calls++
906 r.reasons = append(r.reasons, reason)
907}
908
909func TestBrowserWindow_ClearShellStateClearsHistorySidebarReloader(t *testing.T) {
910 reloader := &recordingHistorySidebarReloader{}
911 bw := &browserWindow{
912 historySidebar: &component.HistorySidebar{},
913 historySidebarReloader: reloader,
914 }
915
916 bw.clearShellState()
917
918 assert.Nil(t, bw.historySidebar)
919 assert.Nil(t, bw.historySidebarReloader)
920}
921
922func TestHistoryChangeAdapter_DispatchesBeforeRefreshingSidebars(t *testing.T) {
923 reloader := &recordingHistorySidebarReloader{}
924 bw := &browserWindow{id: "window-1", sidebarVisible: true, historySidebarReloader: reloader}
925 var dispatched bool
926 var directCalls int
927 app := &App{
928 browserWindows: map[string]*browserWindow{bw.id: bw},
929 dispatchOnMainThread: func(label string, fn func()) syncdispatch.SyncDispatchResult {
930 dispatched = true
931 assert.Equal(t, "ui.history_sidebar_refresh", label)
932 assert.Zero(t, reloader.calls, "sidebar must not be touched before main-thread dispatch runs")
933 directCalls = reloader.calls
934 fn()
935 return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchCompleted}
936 },
937 }
938
939 newHistoryChangeAdapter(app).OnHistoryChanged(t.Context(), dto.HistoryChange{Reasons: []dto.HistoryChangeReason{dto.HistoryChangeReasonVisit}})
940
941 assert.True(t, dispatched)
942 assert.Zero(t, directCalls)
943 assert.Equal(t, 1, reloader.calls)
944 assert.Equal(t, []string{"visit"}, reloader.reasons)
945}
946
947func TestHistoryChangeAdapter_SkipsRefreshWithoutDispatcher(t *testing.T) {
948 reloader := &recordingHistorySidebarReloader{}
949 bw := &browserWindow{id: "window-1", sidebarVisible: true, historySidebarReloader: reloader}
950 app := &App{browserWindows: map[string]*browserWindow{bw.id: bw}}
951
952 newHistoryChangeAdapter(app).OnHistoryChanged(
953 t.Context(),
954 dto.HistoryChange{Reasons: []dto.HistoryChangeReason{dto.HistoryChangeReasonVisit}},
955 )
956
957 assert.Zero(t, reloader.calls)
958}
959
960func TestApp_RefreshVisibleHistorySidebars_OnlyVisibleInitializedWindows(t *testing.T) {
961 visibleOne := &recordingHistorySidebarReloader{}
962 visibleTwo := &recordingHistorySidebarReloader{}
963 hidden := &recordingHistorySidebarReloader{}
964 fallbackSidebar := &component.HistorySidebar{}
965 var fallbackMu sync.Mutex
966 var fallbackIdle []glib.SourceFunc
967 var fallbackTimers []glib.SourceFunc
968 setComponentHistorySidebarField(t, fallbackSidebar, "visible", true)
969 setComponentHistorySidebarField(t, fallbackSidebar, "idleScheduler", func(cb glib.SourceFunc) {
970 fallbackMu.Lock()
971 defer fallbackMu.Unlock()
972 fallbackIdle = append(fallbackIdle, cb)
973 })
974 setComponentHistorySidebarField(t, fallbackSidebar, "timeoutAdd", func(_ uint, cb glib.SourceFunc) uint {
975 fallbackMu.Lock()
976 defer fallbackMu.Unlock()
977 fallbackTimers = append(fallbackTimers, cb)
978 return uint(100 + len(fallbackTimers))
979 })
980 app := &App{browserWindows: map[string]*browserWindow{
981 "visible-1": {id: "visible-1", sidebarVisible: true, historySidebarReloader: visibleOne},
982 "hidden": {id: "hidden", sidebarVisible: false, historySidebarReloader: hidden},
983 "uninitialized": {id: "uninitialized", sidebarVisible: true},
984 "visible-2": {id: "visible-2", sidebarVisible: true, historySidebarReloader: visibleTwo},
985 "visible-fallback": {id: "visible-fallback", sidebarVisible: true, historySidebar: fallbackSidebar},
986 "destroyed-no-op": nil,
987 }}
988
989 require.NotPanics(t, func() {
990 app.refreshVisibleHistorySidebars(dto.HistoryChange{Reasons: []dto.HistoryChangeReason{dto.HistoryChangeReasonClear}})
991 })
992
993 assert.Equal(t, 1, visibleOne.calls)
994 assert.Equal(t, 1, visibleTwo.calls)
995 assert.Zero(t, hidden.calls)
996 assert.Equal(t, []string{"clear"}, visibleOne.reasons)
997 assert.Equal(t, []string{"clear"}, visibleTwo.reasons)
998
999 fallbackMu.Lock()
1000 require.Len(t, fallbackIdle, 1)
1001 idleCb := fallbackIdle[0]
1002 fallbackMu.Unlock()
1003 assert.False(t, idleCb(0))
1004
1005 fallbackMu.Lock()
1006 require.Len(t, fallbackTimers, 1)
1007 timerCb := fallbackTimers[0]
1008 fallbackMu.Unlock()
1009 assert.False(t, timerCb(0))
1010 assert.True(t, getComponentHistorySidebarBoolField(t, fallbackSidebar, "loadStarted"))
1011}
1012
1013func setComponentHistorySidebarField(t *testing.T, hs *component.HistorySidebar, name string, value any) {
1014 t.Helper()
1015 rv := reflect.ValueOf(hs).Elem()
1016 field := rv.FieldByName(name)
1017 require.Truef(t, field.IsValid(), "component.HistorySidebar.%s field missing", name)
1018 reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem().Set(reflect.ValueOf(value))
1019}
1020
1021func getComponentHistorySidebarBoolField(t *testing.T, hs *component.HistorySidebar, name string) bool {
1022 t.Helper()
1023 rv := reflect.ValueOf(hs).Elem()
1024 field := rv.FieldByName(name)
1025 require.Truef(t, field.IsValid(), "component.HistorySidebar.%s field missing", name)
1026 return reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem().Bool()
1027}