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}

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