v0.32.0 0617278

app_vim_mode_test.go RawHistoryFolder

1package ui
2
3import (
4	"context"
5	"testing"
6	"time"
7
8	"github.com/bnema/puregotk/v4/glib"
9	"github.com/stretchr/testify/assert"
10	"github.com/stretchr/testify/mock"
11	"github.com/stretchr/testify/require"
12
13	"github.com/bnema/dumber/internal/application/dto"
14	"github.com/bnema/dumber/internal/application/port"
15	portmocks "github.com/bnema/dumber/internal/application/port/mocks"
16	"github.com/bnema/dumber/internal/application/usecase"
17	"github.com/bnema/dumber/internal/domain/entity"
18	"github.com/bnema/dumber/internal/ui/component"
19	contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"
20	"github.com/bnema/dumber/internal/ui/focus"
21	"github.com/bnema/dumber/internal/ui/input"
22	"github.com/bnema/dumber/internal/ui/layout"
23	"github.com/bnema/dumber/internal/ui/layout/mocks"
24	"github.com/bnema/dumber/internal/ui/theme"
25)
26
27// ---------------------------------------------------------------------------
28// Mock-based PaneView and WorkspaceView creation helpers
29//
30// These are used by vimModeTestFixture to build a minimal test environment
31// without any GTK infrastructure.
32// ---------------------------------------------------------------------------
33
34// newLoadingSkeletonMocks sets up the mock expectations for the loading
35// skeleton that NewPaneView creates internally.
36func newLoadingSkeletonMocks(
37	t *testing.T,
38	factory *mocks.MockWidgetFactory,
39	overlay *mocks.MockOverlayWidget,
40) {
41	t.Helper()
42
43	loadingContainer := mocks.NewMockBoxWidget(t)
44	loadingContent := mocks.NewMockBoxWidget(t)
45	spinner := mocks.NewMockSpinnerWidget(t)
46	logo := mocks.NewMockImageWidget(t)
47	version := mocks.NewMockLabelWidget(t)
48
49	factory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(loadingContainer).Once()
50	loadingContainer.EXPECT().SetHexpand(true).Maybe()
51	loadingContainer.EXPECT().SetVexpand(true).Maybe()
52	loadingContainer.EXPECT().SetHalign(mock.Anything).Maybe()
53	loadingContainer.EXPECT().SetValign(mock.Anything).Maybe()
54	loadingContainer.EXPECT().SetCanFocus(false).Maybe()
55	loadingContainer.EXPECT().SetCanTarget(false).Maybe()
56	loadingContainer.EXPECT().AddCssClass(mock.Anything).Maybe()
57	loadingContainer.EXPECT().SetVisible(mock.Anything).Maybe()
58
59	factory.EXPECT().NewBox(layout.OrientationVertical, 6).Return(loadingContent).Once()
60	loadingContent.EXPECT().SetHalign(mock.Anything).Maybe()
61	loadingContent.EXPECT().SetValign(mock.Anything).Maybe()
62	loadingContent.EXPECT().SetCanFocus(false).Maybe()
63	loadingContent.EXPECT().SetCanTarget(false).Maybe()
64	loadingContent.EXPECT().AddCssClass(mock.Anything).Maybe()
65
66	factory.EXPECT().NewImage().Return(logo).Once()
67	logo.EXPECT().SetHalign(mock.Anything).Maybe()
68	logo.EXPECT().SetValign(mock.Anything).Maybe()
69	logo.EXPECT().SetCanFocus(false).Maybe()
70	logo.EXPECT().SetCanTarget(false).Maybe()
71	logo.EXPECT().SetSizeRequest(mock.Anything, mock.Anything).Maybe()
72	logo.EXPECT().SetPixelSize(mock.Anything).Maybe()
73	logo.EXPECT().AddCssClass(mock.Anything).Maybe()
74	logo.EXPECT().SetFromPaintable(mock.Anything).Maybe()
75
76	factory.EXPECT().NewSpinner().Return(spinner).Once()
77	spinner.EXPECT().SetHalign(mock.Anything).Maybe()
78	spinner.EXPECT().SetValign(mock.Anything).Maybe()
79	spinner.EXPECT().SetCanFocus(false).Maybe()
80	spinner.EXPECT().SetCanTarget(false).Maybe()
81	spinner.EXPECT().SetSizeRequest(mock.Anything, mock.Anything).Maybe()
82	spinner.EXPECT().AddCssClass(mock.Anything).Maybe()
83	spinner.EXPECT().Start().Maybe()
84	spinner.EXPECT().Stop().Maybe()
85
86	factory.EXPECT().NewLabel(mock.Anything).Return(version).Once()
87	version.EXPECT().SetHalign(mock.Anything).Maybe()
88	version.EXPECT().SetValign(mock.Anything).Maybe()
89	version.EXPECT().SetCanFocus(false).Maybe()
90	version.EXPECT().SetCanTarget(false).Maybe()
91	version.EXPECT().SetMaxWidthChars(mock.Anything).Maybe()
92	version.EXPECT().SetEllipsize(mock.Anything).Maybe()
93	version.EXPECT().AddCssClass(mock.Anything).Maybe()
94
95	loadingContent.EXPECT().Append(logo).Maybe()
96	loadingContent.EXPECT().Append(spinner).Maybe()
97	loadingContent.EXPECT().Append(version).Maybe()
98	loadingContainer.EXPECT().Append(loadingContent).Maybe()
99
100	overlay.EXPECT().AddOverlay(loadingContainer).Once()
101	overlay.EXPECT().SetClipOverlay(loadingContainer, false).Once()
102	overlay.EXPECT().SetMeasureOverlay(loadingContainer, false).Once()
103}
104
105// createTestPaneView builds a mock-backed PaneView.  Returns the PaneView and
106// its overlay mock so subsequent vim-mode indicator / pulse expectations can
107// be set up by the caller.
108func createTestPaneView(
109	t *testing.T,
110	factory *mocks.MockWidgetFactory,
111	paneID entity.PaneID,
112) (*component.PaneView, *mocks.MockOverlayWidget) {
113	t.Helper()
114
115	overlay := mocks.NewMockOverlayWidget(t)
116	borderBox := mocks.NewMockBoxWidget(t)
117
118	// Overlay
119	factory.EXPECT().NewOverlay().Return(overlay).Once()
120	overlay.EXPECT().SetHexpand(true).Once()
121	overlay.EXPECT().SetVexpand(true).Once()
122	overlay.EXPECT().SetVisible(true).Once()
123	overlay.EXPECT().AddCssClass("pane-overlay").Once()
124
125	// Loading skeleton
126	newLoadingSkeletonMocks(t, factory, overlay)
127
128	// Border box
129	factory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(borderBox).Once()
130	borderBox.EXPECT().SetCanFocus(false).Once()
131	borderBox.EXPECT().SetCanTarget(false).Once()
132	borderBox.EXPECT().AddCssClass("pane-border").Once()
133	borderBox.EXPECT().SetHexpand(true).Once()
134	borderBox.EXPECT().SetVexpand(true).Once()
135	overlay.EXPECT().AddOverlay(borderBox).Once()
136	overlay.EXPECT().SetClipOverlay(borderBox, false).Once()
137	overlay.EXPECT().SetMeasureOverlay(borderBox, false).Once()
138
139	pv := component.NewPaneView(context.Background(), factory, paneID, nil)
140	require.NotNil(t, pv)
141
142	return pv, overlay
143}
144
145// enterVimMode marks the owning pane with the pane-local visual accent.
146func enterVimMode(
147	t *testing.T,
148	app *App,
149	overlay *mocks.MockOverlayWidget,
150	bw *browserWindow,
151) {
152	t.Helper()
153	overlay.EXPECT().AddCssClass("vim-mode-active").Once()
154	app.handleVimModeOwnership(context.Background(), bw, input.ModeVim, input.ModeNormal)
155}
156
157func expectVimModeAccentHidden(overlay *mocks.MockOverlayWidget) {
158	overlay.EXPECT().RemoveCssClass("vim-mode-active").Once()
159}
160
161type accessibilityEnablingWebView struct {
162	*portmocks.MockWebView
163	enableAccessibilityCalls int
164}
165
166func newAccessibilityEnablingWebView(t *testing.T, id port.WebViewID) *accessibilityEnablingWebView {
167	t.Helper()
168	wv := portmocks.NewMockWebView(t)
169	wv.EXPECT().ID().Return(id).Once()
170	return &accessibilityEnablingWebView{MockWebView: wv}
171}
172
173func (wv *accessibilityEnablingWebView) EnableAccessibility() {
174	wv.enableAccessibilityCalls++
175}
176
177type semanticNavigatingWebView struct {
178	*portmocks.MockWebView
179	requests                      []dto.SemanticNavigationRequest
180	activateNavigationTargetCalls int
181	clearNavigationHighlightCalls int
182}
183
184func newSemanticNavigatingWebView(t *testing.T, id port.WebViewID) *semanticNavigatingWebView {
185	t.Helper()
186	wv := portmocks.NewMockWebView(t)
187	wv.EXPECT().ID().Return(id)
188	return &semanticNavigatingWebView{MockWebView: wv}
189}
190
191func (wv *semanticNavigatingWebView) NavigateSemantic(_ context.Context, request dto.SemanticNavigationRequest) error {
192	wv.requests = append(wv.requests, request)
193	return nil
194}
195
196func (wv *semanticNavigatingWebView) ActivateSemanticNavigationTarget(context.Context) error {
197	wv.activateNavigationTargetCalls++
198	return nil
199}
200
201func (wv *semanticNavigatingWebView) ClearSemanticNavigationHighlight(context.Context) error {
202	wv.clearNavigationHighlightCalls++
203	return nil
204}
205
206type inputFocusingWebView struct {
207	*portmocks.MockWebView
208	focusNextInputCalls      int
209	pageFocusNavigationCalls []bool
210}
211
212func newInputFocusingWebView(t *testing.T, id port.WebViewID) *inputFocusingWebView {
213	t.Helper()
214	wv := portmocks.NewMockWebView(t)
215	wv.EXPECT().ID().Return(id).Once()
216	return &inputFocusingWebView{MockWebView: wv}
217}
218
219func (wv *inputFocusingWebView) FocusNextInput() {
220	wv.focusNextInputCalls++
221}
222
223func (wv *inputFocusingWebView) NavigatePageFocus(backward bool) {
224	wv.pageFocusNavigationCalls = append(wv.pageFocusNavigationCalls, backward)
225}
226
227func newVimModeAccessibilityFixture(t *testing.T) (*App, *browserWindow, entity.PaneID) {
228	t.Helper()
229	paneID := entity.PaneID("pane-a")
230	ws := &entity.Workspace{
231		ID:           "ws-1",
232		ActivePaneID: paneID,
233		Root: &entity.PaneNode{
234			ID:   "ws-1-root",
235			Pane: entity.NewPane(paneID),
236		},
237	}
238	tab := &entity.Tab{ID: "tab-1", Workspace: ws}
239	tabs := entity.NewTabList()
240	tabs.Add(tab)
241	tabs.SetActive(tab.ID)
242	bw := &browserWindow{id: "win-1", tabs: tabs}
243	return &App{
244		contentCoord: contentcoord.NewCoordinator(context.Background(), nil, nil, nil, nil, nil, nil, nil),
245	}, bw, paneID
246}
247
248// setupWorkspaceViewMocks creates a mock-backed WorkspaceView.
249func setupWorkspaceViewMocks(
250	t *testing.T,
251	factory *mocks.MockWidgetFactory,
252) *component.WorkspaceView {
253	t.Helper()
254
255	box := mocks.NewMockBoxWidget(t)
256	overlay := mocks.NewMockOverlayWidget(t)
257
258	factory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(box).Once()
259	box.EXPECT().SetHexpand(true).Once()
260	box.EXPECT().SetVexpand(true).Once()
261	box.EXPECT().SetVisible(true).Once()
262
263	factory.EXPECT().NewOverlay().Return(overlay).Once()
264	overlay.EXPECT().SetHexpand(true).Once()
265	overlay.EXPECT().SetVexpand(true).Once()
266	overlay.EXPECT().SetChild(box).Once()
267	overlay.EXPECT().SetVisible(true).Once()
268
269	wv := component.NewWorkspaceView(context.Background(), factory)
270	require.NotNil(t, wv)
271
272	return wv
273}
274
275// newKeyboardHandlerInVimMode creates a KeyboardHandler whose Mode() returns
276// ModeVim — no GTK widgets required.
277func newKeyboardHandlerInVimMode(t *testing.T) *input.KeyboardHandler {
278	t.Helper()
279	kh := input.NewKeyboardHandler(
280		context.Background(),
281		&entity.WorkspaceConfig{},
282		&entity.SessionConfig{},
283	)
284	kh.EnterVimMode()
285	return kh
286}
287
288func bindVimModeKeyboardHandler(t *testing.T, app *App, bw *browserWindow) *input.KeyboardHandler {
289	t.Helper()
290	kh := newKeyboardHandlerInVimMode(t)
291	kh.SetOnModeChange(func(from, to input.Mode) {
292		app.handleModeChange(context.Background(), bw, from, to)
293	})
294	bw.keyboardHandler = kh
295	return kh
296}
297
298// ---------------------------------------------------------------------------
299// vimModeTestFixture — complete, minimal App + workspace environment
300//
301// Provides two browser windows, each with a two-pane workspace.  The active
302// pane on both workspaces starts at "pane-a".
303// ---------------------------------------------------------------------------
304
305type vimModeTestFixture struct {
306	app  *App
307	bw1  *browserWindow
308	bw2  *browserWindow
309	pv1A *component.PaneView // bw1 / pane-a (active)
310	pv1B *component.PaneView // bw1 / pane-b
311	pv2A *component.PaneView // bw2 / pane-a (active)
312	pv2B *component.PaneView // bw2 / pane-b
313
314	factory   *mocks.MockWidgetFactory
315	overlay1A *mocks.MockOverlayWidget
316	overlay1B *mocks.MockOverlayWidget
317	overlay2A *mocks.MockOverlayWidget
318	overlay2B *mocks.MockOverlayWidget
319}
320
321func newVimModeTestFixture(t *testing.T) *vimModeTestFixture {
322	t.Helper()
323	factory := mocks.NewMockWidgetFactory(t)
324
325	// ---- Browser window 1 ----
326	pv1A, ol1A := createTestPaneView(t, factory, "pane-a")
327	pv1B, ol1B := createTestPaneView(t, factory, "pane-b")
328
329	ws1 := &entity.Workspace{
330		ID:           "ws-1",
331		ActivePaneID: "pane-a",
332		Root: &entity.PaneNode{
333			ID: "ws-1-root",
334			Children: []*entity.PaneNode{
335				{ID: "ws-1-pane-a", Pane: entity.NewPane("pane-a")},
336				{ID: "ws-1-pane-b", Pane: entity.NewPane("pane-b")},
337			},
338			SplitDir: entity.SplitHorizontal,
339		},
340	}
341	tab1 := &entity.Tab{ID: "tab-1", Workspace: ws1}
342	tabs1 := entity.NewTabList()
343	tabs1.Add(tab1)
344	tabs1.SetActive(tab1.ID)
345
346	wv1 := setupWorkspaceViewMocks(t, factory)
347	wv1.SetPaneViewsForTest(map[entity.PaneID]*component.PaneView{
348		"pane-a": pv1A, "pane-b": pv1B,
349	})
350
351	bw1 := &browserWindow{id: "win-1", tabs: tabs1}
352
353	// ---- Browser window 2 ----
354	pv2A, ol2A := createTestPaneView(t, factory, "pane-a")
355	pv2B, ol2B := createTestPaneView(t, factory, "pane-b")
356
357	ws2 := &entity.Workspace{
358		ID:           "ws-2",
359		ActivePaneID: "pane-a",
360		Root: &entity.PaneNode{
361			ID: "ws-2-root",
362			Children: []*entity.PaneNode{
363				{ID: "ws-2-pane-a", Pane: entity.NewPane("pane-a")},
364				{ID: "ws-2-pane-b", Pane: entity.NewPane("pane-b")},
365			},
366			SplitDir: entity.SplitHorizontal,
367		},
368	}
369	tab2 := &entity.Tab{ID: "tab-2", Workspace: ws2}
370	tabs2 := entity.NewTabList()
371	tabs2.Add(tab2)
372	tabs2.SetActive(tab2.ID)
373
374	wv2 := setupWorkspaceViewMocks(t, factory)
375	wv2.SetPaneViewsForTest(map[entity.PaneID]*component.PaneView{
376		"pane-a": pv2A, "pane-b": pv2B,
377	})
378
379	bw2 := &browserWindow{id: "win-2", tabs: tabs2}
380
381	app := &App{
382		workspaceViews: map[entity.TabID]*component.WorkspaceView{
383			"tab-1": wv1, "tab-2": wv2,
384		},
385		browserWindows: map[string]*browserWindow{
386			"win-1": bw1, "win-2": bw2,
387		},
388		lastFocusedWindowID: "win-1",
389	}
390
391	return &vimModeTestFixture{
392		app: app, bw1: bw1, bw2: bw2,
393		pv1A: pv1A, pv1B: pv1B, pv2A: pv2A, pv2B: pv2B,
394		factory:   factory,
395		overlay1A: ol1A, overlay1B: ol1B,
396		overlay2A: ol2A, overlay2B: ol2B,
397	}
398}
399
400func newSingleWindowVimModeFixture(t *testing.T) *vimModeTestFixture {
401	t.Helper()
402	f := newVimModeTestFixture(t)
403	delete(f.app.browserWindows, "win-2")
404	delete(f.app.workspaceViews, entity.TabID("tab-2"))
405	f.bw2 = nil
406	f.pv2A = nil
407	f.pv2B = nil
408	f.overlay2A = nil
409	f.overlay2B = nil
410	return f
411}
412
413// ============================================================================
414// 1. Entering Vim mode marks only the active pane
415// ============================================================================
416
417func TestVimMode_Enter_SetsOwnershipOnActivePane(t *testing.T) {
418	f := newVimModeTestFixture(t)
419
420	enterVimMode(t, f.app, f.overlay1A, f.bw1)
421
422	assert.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID,
423		"entering vim mode sets ownership on the active pane")
424	assert.True(t, f.pv1A.IsVimMode(),
425		"the active pane view enters vim mode")
426	assert.False(t, f.pv1B.IsVimMode(),
427		"the inactive pane is NOT in vim mode")
428	assert.Empty(t, f.bw2.vimModePaneID,
429		"second window is untouched")
430	assert.False(t, f.pv2A.IsVimMode(),
431		"second window active pane is NOT in vim mode")
432}
433
434func TestVimMode_Enter_MarksCorrectWindow(t *testing.T) {
435	f := newVimModeTestFixture(t)
436
437	enterVimMode(t, f.app, f.overlay2A, f.bw2)
438
439	assert.Equal(t, entity.PaneID("pane-a"), f.bw2.vimModePaneID)
440	assert.True(t, f.pv2A.IsVimMode())
441	assert.Empty(t, f.bw1.vimModePaneID)
442	assert.False(t, f.pv1A.IsVimMode())
443}
444
445func TestVimMode_Enter_EnablesAccessibilityForSupportedActiveWebView(t *testing.T) {
446	app, bw, paneID := newVimModeAccessibilityFixture(t)
447	wv := newAccessibilityEnablingWebView(t, 1)
448	app.contentCoord.RegisterPopupWebView(paneID, wv)
449
450	app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim)
451
452	assert.Equal(t, 1, wv.enableAccessibilityCalls)
453}
454
455func TestVimMode_Enter_AccessibilityNoopsForUnsupportedActiveWebView(t *testing.T) {
456	app, bw, paneID := newVimModeAccessibilityFixture(t)
457	wv := portmocks.NewMockWebView(t)
458	wv.EXPECT().ID().Return(port.WebViewID(2)).Once()
459	app.contentCoord.RegisterPopupWebView(paneID, wv)
460
461	app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim)
462}
463
464func TestVimMode_Enter_AccessibilityNoopsWhenActiveWebViewMissing(t *testing.T) {
465	app, bw, _ := newVimModeAccessibilityFixture(t)
466
467	app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim)
468}
469
470func TestVimMode_PreloadAccessibilityEnablesShownWebViewWhenConfigured(t *testing.T) {
471	app, _, paneID := newVimModeAccessibilityFixture(t)
472	wv := newAccessibilityEnablingWebView(t, 4)
473	app.contentCoord.RegisterPopupWebView(paneID, wv)
474	app.runtimeConfig = runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{
475		UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{
476			VimMode: entity.VimModeConfig{PreloadAccessibility: true},
477		}},
478	})
479
480	app.preloadAccessibilityForShownPane(paneID)
481
482	assert.Equal(t, 1, wv.enableAccessibilityCalls)
483}
484
485func TestVimMode_PreloadAccessibilityNoopsWhenDisabled(t *testing.T) {
486	app, _, paneID := newVimModeAccessibilityFixture(t)
487	wv := newAccessibilityEnablingWebView(t, 5)
488	app.contentCoord.RegisterPopupWebView(paneID, wv)
489
490	app.preloadAccessibilityForShownPane(paneID)
491
492	assert.Equal(t, 0, wv.enableAccessibilityCalls)
493}
494
495func TestVimNavigationHighlightColorUsesCurrentThemeAccent(t *testing.T) {
496	resolved := entity.ResolvedTheme{
497		PrefersDark: true,
498		DarkPalette: entity.ColorPalette{
499			Accent: "#22c55e",
500		},
501	}
502	app := &App{deps: &Dependencies{Theme: theme.NewManager(context.Background(), resolved)}}
503
504	assert.Equal(t, "#22c55e", app.vimNavigationHighlightColor())
505}
506
507func TestVimMode_SequenceActionNavigatesActiveWebViewHeading(t *testing.T) {
508	app, bw, paneID := newVimModeAccessibilityFixture(t)
509	wv := newSemanticNavigatingWebView(t, 4)
510	app.contentCoord.RegisterPopupWebView(paneID, wv)
511
512	app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 3)
513	app.navigateVimSequenceAction(context.Background(), bw, "heading-prev", 0)
514	app.navigateVimSequenceAction(context.Background(), bw, "code-next", 1)
515
516	assert.Equal(t, []dto.SemanticNavigationRequest{
517		{Target: dto.SemanticNavigationTargetHeading, Direction: dto.SemanticNavigationForward, Count: 3},
518		{Target: dto.SemanticNavigationTargetHeading, Direction: dto.SemanticNavigationBackward, Count: 0},
519	}, wv.requests)
520}
521
522func TestVimMode_ConfirmActivatesSelectedHeadingLink(t *testing.T) {
523	app, bw, paneID := newVimModeAccessibilityFixture(t)
524	wv := newSemanticNavigatingWebView(t, 12)
525	app.contentCoord.RegisterPopupWebView(paneID, wv)
526
527	app.navigateVimSequenceAction(context.Background(), bw, "confirm", 0)
528
529	assert.Equal(t, 1, wv.activateNavigationTargetCalls)
530}
531
532func TestVimMode_SequenceActionFocusesNextInput(t *testing.T) {
533	app, bw, paneID := newVimModeAccessibilityFixture(t)
534	wv := newInputFocusingWebView(t, 6)
535	app.contentCoord.RegisterPopupWebView(paneID, wv)
536
537	app.navigateVimSequenceAction(context.Background(), bw, "focus-input", 1)
538
539	assert.Equal(t, 1, wv.focusNextInputCalls)
540}
541
542func TestVimMode_Leave_ClearsNavigationHighlight(t *testing.T) {
543	app, bw, paneID := newVimModeAccessibilityFixture(t)
544	wv := newSemanticNavigatingWebView(t, 8)
545	app.contentCoord.RegisterPopupWebView(paneID, wv)
546	app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 1)
547	app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 1)
548
549	app.handleModeChange(context.Background(), bw, input.ModeVim, input.ModeNormal)
550
551	assert.Equal(t, 1, wv.clearNavigationHighlightCalls)
552	assert.Empty(t, bw.vimNavigationHighlightedWebViews)
553	assert.Empty(t, bw.vimNavigationHighlightedWebViewIDs)
554}
555
556func TestVimMode_Leave_ClearsNavigationHighlightAfterPaneSwitch(t *testing.T) {
557	f := newSingleWindowVimModeFixture(t)
558	f.app.contentCoord = contentcoord.NewCoordinator(context.Background(), nil, nil, nil, nil, nil, nil, nil)
559	oldWebView := newSemanticNavigatingWebView(t, 9)
560	newWebView := newSemanticNavigatingWebView(t, 10)
561	f.app.contentCoord.RegisterPopupWebView("pane-a", oldWebView)
562	f.app.contentCoord.RegisterPopupWebView("pane-b", newWebView)
563
564	f.app.navigateVimSequenceAction(context.Background(), f.bw1, "heading-next", 1)
565	f.bw1.tabs.ActiveTab().Workspace.ActivePaneID = "pane-b"
566	f.app.handleModeChange(context.Background(), f.bw1, input.ModeVim, input.ModeNormal)
567
568	assert.Equal(t, 1, oldWebView.clearNavigationHighlightCalls)
569	assert.Equal(t, 0, newWebView.clearNavigationHighlightCalls)
570}
571
572func TestVimMode_Leave_ClearsNavigationHighlightAfterTabSwitch(t *testing.T) {
573	app, bw, paneID := newVimModeAccessibilityFixture(t)
574	oldWebView := newSemanticNavigatingWebView(t, 11)
575	app.contentCoord.RegisterPopupWebView(paneID, oldWebView)
576	otherPaneID := entity.PaneID("pane-b")
577	otherTab := entity.NewTab("tab-2", "ws-2", entity.NewPane(otherPaneID))
578	bw.tabs.Add(otherTab)
579	bw.tabs.SetActive("tab-1")
580
581	app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 1)
582	bw.tabs.SetActive(otherTab.ID)
583	app.handleModeChange(context.Background(), bw, input.ModeVim, input.ModeNormal)
584
585	assert.Equal(t, 1, oldWebView.clearNavigationHighlightCalls)
586}
587
588func TestVimMode_NativePageFocusNavigationUsesActiveEditableWebView(t *testing.T) {
589	app, bw, paneID := newVimModeAccessibilityFixture(t)
590	wv := newInputFocusingWebView(t, 7)
591	app.contentCoord.RegisterPopupWebView(paneID, wv)
592	app.pageEditableFocusByPane = map[entity.PaneID]bool{paneID: true}
593
594	assert.True(t, app.navigatePageFocus(context.Background(), bw, true))
595	assert.Equal(t, []bool{true}, wv.pageFocusNavigationCalls)
596}
597
598func TestVimMode_SequenceActionNoopsForUnsupportedActiveWebView(t *testing.T) {
599	app, bw, paneID := newVimModeAccessibilityFixture(t)
600	wv := portmocks.NewMockWebView(t)
601	wv.EXPECT().ID().Return(port.WebViewID(5)).Once()
602	app.contentCoord.RegisterPopupWebView(paneID, wv)
603
604	app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 1)
605	app.navigateVimSequenceAction(context.Background(), bw, "focus-input", 1)
606}
607
608func TestVimMode_AccessibilityOnlyEnabledOnEnteringVimMode(t *testing.T) {
609	app, bw, paneID := newVimModeAccessibilityFixture(t)
610	wv := newAccessibilityEnablingWebView(t, 3)
611	app.contentCoord.RegisterPopupWebView(paneID, wv)
612
613	app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeTab)
614	app.handleModeChange(context.Background(), bw, input.ModeVim, input.ModeNormal)
615	app.handleModeChange(context.Background(), bw, input.ModeVim, input.ModeVim)
616	assert.Equal(t, 0, wv.enableAccessibilityCalls)
617
618	app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim)
619	assert.Equal(t, 1, wv.enableAccessibilityCalls)
620
621	app.handleModeChange(context.Background(), bw, input.ModePane, input.ModeVim)
622	assert.Equal(t, 2, wv.enableAccessibilityCalls)
623}
624
625// ============================================================================
626// 2. Leaving Vim mode clears ownership
627// ============================================================================
628
629func TestVimMode_Leave_ClearsOwnershipFromOwningPane(t *testing.T) {
630	f := newVimModeTestFixture(t)
631
632	// Enter first
633	enterVimMode(t, f.app, f.overlay1A, f.bw1)
634	require.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID)
635
636	// Leave — SetVimMode(false) hides indicator and removes the CSS class
637	expectVimModeAccentHidden(f.overlay1A)
638	f.app.handleVimModeOwnership(context.Background(), f.bw1, input.ModeNormal, input.ModeVim)
639
640	assert.Empty(t, f.bw1.vimModePaneID,
641		"leaving vim mode clears ownership field")
642	assert.False(t, f.pv1A.IsVimMode(),
643		"the previously owning pane exits vim mode")
644}
645
646// ============================================================================
647// 3. Transfer within the same window
648// ============================================================================
649
650func TestVimMode_Transfer_MovesOwnershipToNewPane(t *testing.T) {
651	f := newVimModeTestFixture(t)
652
653	// Enter vim mode on bw1 pane-a — save label for hide expectation
654	enterVimMode(t, f.app, f.overlay1A, f.bw1)
655	require.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID)
656
657	// Give bw1 a keyboard handler in vim mode so transfer performs the switch
658	f.bw1.keyboardHandler = newKeyboardHandlerInVimMode(t)
659
660	// transferVimModeOwnershipToPane calls SetVimMode(false) on the old
661	// pane before SetVimMode(true) on the new pane.  Set up both.
662	expectVimModeAccentHidden(f.overlay1A)
663
664	// Set up the pane-local accent for pane-b.
665	f.overlay1B.EXPECT().AddCssClass("vim-mode-active").Once()
666
667	// Transfer ownership to pane-b
668	f.app.transferVimModeOwnershipToPane(context.Background(), f.bw1, entity.PaneID("pane-b"))
669
670	assert.Equal(t, entity.PaneID("pane-b"), f.bw1.vimModePaneID,
671		"ownership transferred to new pane")
672	assert.False(t, f.pv1A.IsVimMode(),
673		"previous owning pane deactivated")
674	assert.True(t, f.pv1B.IsVimMode(),
675		"new pane is now in vim mode")
676}
677
678func TestVimMode_Transfer_NoVimModeClearsStale(t *testing.T) {
679	f := newVimModeTestFixture(t)
680
681	f.bw1.vimModePaneID = entity.PaneID("pane-a")
682	f.bw1.keyboardHandler = input.NewKeyboardHandler(
683		context.Background(),
684		&entity.WorkspaceConfig{},
685		&entity.SessionConfig{},
686	)
687	// No EnterVimMode — handler stays in ModeNormal.
688
689	f.app.transferVimModeOwnershipToPane(context.Background(), f.bw1, entity.PaneID("pane-b"))
690
691	assert.Empty(t, f.bw1.vimModePaneID,
692		"stale ownership cleared when window is not in vim mode")
693	assert.False(t, f.pv1A.IsVimMode())
694	assert.False(t, f.pv1B.IsVimMode())
695}
696
697func TestVimMode_Transfer_NilBwDoesNotCrash(t *testing.T) {
698	app := &App{}
699	app.transferVimModeOwnershipToPane(context.Background(), nil, entity.PaneID("pane-1"))
700}
701
702func TestVimMode_Transfer_SamePaneIsNoop(t *testing.T) {
703	app := &App{}
704	paneID := entity.PaneID("pane-a")
705	bw := &browserWindow{vimModePaneID: paneID}
706	app.transferVimModeOwnershipToPane(context.Background(), bw, paneID)
707	assert.Equal(t, paneID, bw.vimModePaneID)
708}
709
710func TestVimMode_Transfer_EmptyOwnershipIsNoop(t *testing.T) {
711	app := &App{}
712	bw := &browserWindow{}
713	app.transferVimModeOwnershipToPane(context.Background(), bw, entity.PaneID("pane-a"))
714	assert.Empty(t, bw.vimModePaneID)
715}
716
717// ============================================================================
718// 4. Pulse targeting — normal vs fast
719// ============================================================================
720
721func setUpNormalPulse(overlay *mocks.MockOverlayWidget) {
722	overlay.EXPECT().RemoveCssClass("vim-mode-pulse").Once()
723	overlay.EXPECT().RemoveCssClass("vim-mode-pulse-fast").Once()
724	overlay.EXPECT().RemoveCssClass("vim-mode-pulse-cycle-a").Once()
725	overlay.EXPECT().RemoveCssClass("vim-mode-pulse-cycle-b").Once()
726	overlay.EXPECT().AddCssClass("vim-mode-pulse").Once()
727	overlay.EXPECT().AddCssClass("vim-mode-pulse-cycle-a").Once()
728}
729
730func setUpFastPulse(overlay *mocks.MockOverlayWidget) {
731	overlay.EXPECT().RemoveCssClass("vim-mode-pulse").Once()
732	overlay.EXPECT().RemoveCssClass("vim-mode-pulse-fast").Once()
733	overlay.EXPECT().RemoveCssClass("vim-mode-pulse-cycle-a").Once()
734	overlay.EXPECT().RemoveCssClass("vim-mode-pulse-cycle-b").Once()
735	overlay.EXPECT().AddCssClass("vim-mode-pulse-fast").Once()
736	overlay.EXPECT().AddCssClass("vim-mode-pulse-cycle-a").Once()
737}
738
739func TestVimMode_Pulse_NormalTriggersOnOwningPane(t *testing.T) {
740	f := newVimModeTestFixture(t)
741
742	enterVimMode(t, f.app, f.overlay1A, f.bw1)
743	require.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID)
744
745	// Pulse needs the indicator to exist — it was created lazily during
746	// enterVimMode, but we also need to set up pulse expectations.
747	// (enterVimMode created it, so we just set up pulse mocks.)
748	setUpNormalPulse(f.overlay1A)
749
750	f.app.triggerVimModePulse(context.Background(), false)
751}
752
753func TestVimMode_Pulse_FastTriggersOnOwningPane(t *testing.T) {
754	f := newVimModeTestFixture(t)
755
756	enterVimMode(t, f.app, f.overlay1A, f.bw1)
757	require.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID)
758
759	setUpFastPulse(f.overlay1A)
760
761	f.app.triggerVimModePulse(context.Background(), true)
762}
763
764func TestVimMode_Pulse_NoOwnerIsNoop(t *testing.T) {
765	f := newVimModeTestFixture(t)
766	f.app.triggerVimModePulse(context.Background(), false)
767	f.app.triggerVimModePulse(context.Background(), true)
768	// No pane has ownership — no pulse expectations needed.
769}
770
771// ============================================================================
772// 5. Multi-window isolation
773// ============================================================================
774
775func TestVimMode_MultiWindow_TwoWindowsEnterLeave(t *testing.T) {
776	f := newVimModeTestFixture(t)
777
778	enterVimMode(t, f.app, f.overlay1A, f.bw1)
779	enterVimMode(t, f.app, f.overlay2A, f.bw2)
780
781	assert.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID)
782	assert.Equal(t, entity.PaneID("pane-a"), f.bw2.vimModePaneID)
783
784	// Leave bw1 only
785	expectVimModeAccentHidden(f.overlay1A)
786	f.app.handleVimModeOwnership(context.Background(), f.bw1, input.ModeNormal, input.ModeVim)
787
788	assert.Empty(t, f.bw1.vimModePaneID)
789	assert.False(t, f.pv1A.IsVimMode())
790
791	// bw2 unchanged
792	assert.Equal(t, entity.PaneID("pane-a"), f.bw2.vimModePaneID)
793	assert.True(t, f.pv2A.IsVimMode())
794}
795
796func TestVimMode_MultiWindow_TransferIsolated(t *testing.T) {
797	f := newVimModeTestFixture(t)
798
799	enterVimMode(t, f.app, f.overlay1A, f.bw1)
800	enterVimMode(t, f.app, f.overlay2A, f.bw2)
801
802	f.bw1.keyboardHandler = newKeyboardHandlerInVimMode(t)
803
804	// transferVimModeOwnershipToPane calls SetVimMode(false) on old pane first
805	expectVimModeAccentHidden(f.overlay1A)
806
807	// Transfer bw1 to pane-b.
808	f.overlay1B.EXPECT().AddCssClass("vim-mode-active").Once()
809	f.app.transferVimModeOwnershipToPane(context.Background(), f.bw1, entity.PaneID("pane-b"))
810
811	assert.Equal(t, entity.PaneID("pane-b"), f.bw1.vimModePaneID)
812	assert.True(t, f.pv1B.IsVimMode())
813	assert.False(t, f.pv1A.IsVimMode())
814
815	// bw2 unchanged
816	assert.Equal(t, entity.PaneID("pane-a"), f.bw2.vimModePaneID)
817	assert.True(t, f.pv2A.IsVimMode())
818}
819
820func TestVimMode_MultiWindow_PulseIsolated(t *testing.T) {
821	f := newVimModeTestFixture(t)
822
823	enterVimMode(t, f.app, f.overlay1A, f.bw1)
824	enterVimMode(t, f.app, f.overlay2A, f.bw2)
825
826	// Pulse on bw1 (lastFocusedWindowID = "win-1")
827	setUpNormalPulse(f.overlay1A)
828	f.app.triggerVimModePulse(context.Background(), false)
829
830	// bw2 pane still in vim mode
831	assert.True(t, f.pv2A.IsVimMode())
832}
833
834// ============================================================================
835// Edge cases — nil / no-workspace / startup guards
836// ============================================================================
837
838func TestVimMode_Enter_NoWorkspaceIsNoop(t *testing.T) {
839	app := &App{}
840	bw := &browserWindow{id: "win-1"}
841	app.handleVimModeOwnership(context.Background(), bw, input.ModeVim, input.ModeNormal)
842	assert.Empty(t, bw.vimModePaneID)
843}
844
845func TestVimMode_Clear_NilBwDoesNotCrash(t *testing.T) {
846	(&App{}).clearVimModeOwnership(context.Background(), nil)
847}
848
849func TestVimMode_Clear_NoPaneID(t *testing.T) {
850	app := &App{}
851	bw := &browserWindow{id: "test-win"}
852	app.clearVimModeOwnership(context.Background(), bw)
853	assert.Empty(t, bw.vimModePaneID)
854}
855
856func TestVimMode_Clear_StalePaneWithoutWorkspace(t *testing.T) {
857	app := &App{}
858	bw := &browserWindow{id: "test-win", vimModePaneID: entity.PaneID("stale-pane")}
859	app.clearVimModeOwnership(context.Background(), bw)
860	assert.Empty(t, bw.vimModePaneID)
861}
862
863func TestVimMode_HandleModeChange_NilBwDoesNotCrash(t *testing.T) {
864	(&App{}).handleModeChange(context.Background(), nil, input.ModeNormal, input.ModeVim)
865}
866
867func TestVimMode_EditableFocusOnActivePaneExitsVimMode(t *testing.T) {
868	f := newSingleWindowVimModeFixture(t)
869	enterVimMode(t, f.app, f.overlay1A, f.bw1)
870	kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)
871
872	expectVimModeAccentHidden(f.overlay1A)
873	f.app.handlePageEditableFocusChanged(context.Background(), entity.PaneID("pane-a"), true)
874
875	assert.Equal(t, input.ModeNormal, kh.Mode())
876	assert.Empty(t, f.bw1.vimModePaneID)
877	assert.False(t, f.pv1A.IsVimMode())
878}
879
880func TestVimMode_EditableFocusOnInactivePaneDoesNotExitVimMode(t *testing.T) {
881	f := newSingleWindowVimModeFixture(t)
882	enterVimMode(t, f.app, f.overlay1A, f.bw1)
883	kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)
884
885	f.app.handlePageEditableFocusChanged(context.Background(), entity.PaneID("pane-b"), true)
886
887	assert.Equal(t, input.ModeVim, kh.Mode())
888	assert.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID)
889	assert.True(t, f.pv1A.IsVimMode())
890}
891
892func TestVimMode_PaneSwitchToEditablePaneExitsVimMode(t *testing.T) {
893	f := newSingleWindowVimModeFixture(t)
894	enterVimMode(t, f.app, f.overlay1A, f.bw1)
895	kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)
896	f.app.pageEditableFocusByPane = map[entity.PaneID]bool{
897		"pane-b": true,
898	}
899
900	expectVimModeAccentHidden(f.overlay1A)
901	f.app.transferVimModeOwnershipToPane(context.Background(), f.bw1, entity.PaneID("pane-b"))
902
903	assert.Equal(t, input.ModeNormal, kh.Mode())
904	assert.Empty(t, f.bw1.vimModePaneID)
905	assert.False(t, f.pv1A.IsVimMode())
906	assert.False(t, f.pv1B.IsVimMode())
907}
908
909func TestVimMode_BackgroundWindowEditableFocusDoesNotExitFocusedWindowVimMode(t *testing.T) {
910	factory := mocks.NewMockWidgetFactory(t)
911	pv1, overlay1 := createTestPaneView(t, factory, "pane-a")
912	pv2, _ := createTestPaneView(t, factory, "pane-x")
913
914	ws1 := &entity.Workspace{ID: "ws-1", ActivePaneID: "pane-a", Root: &entity.PaneNode{ID: "ws-1-pane-a", Pane: entity.NewPane("pane-a")}}
915	ws2 := &entity.Workspace{ID: "ws-2", ActivePaneID: "pane-x", Root: &entity.PaneNode{ID: "ws-2-pane-x", Pane: entity.NewPane("pane-x")}}
916	bw1 := &browserWindow{id: "win-1", tabs: entity.NewTabList()}
917	bw2 := &browserWindow{id: "win-2", tabs: entity.NewTabList()}
918	tab1 := &entity.Tab{ID: "tab-1", Workspace: ws1}
919	tab2 := &entity.Tab{ID: "tab-2", Workspace: ws2}
920	bw1.tabs.Add(tab1)
921	bw1.tabs.SetActive(tab1.ID)
922	bw2.tabs.Add(tab2)
923	bw2.tabs.SetActive(tab2.ID)
924	view1 := setupWorkspaceViewMocks(t, factory)
925	view1.SetPaneViewsForTest(map[entity.PaneID]*component.PaneView{"pane-a": pv1})
926	view2 := setupWorkspaceViewMocks(t, factory)
927	view2.SetPaneViewsForTest(map[entity.PaneID]*component.PaneView{"pane-x": pv2})
928
929	app := &App{
930		workspaceViews:      map[entity.TabID]*component.WorkspaceView{"tab-1": view1, "tab-2": view2},
931		browserWindows:      map[string]*browserWindow{"win-1": bw1, "win-2": bw2},
932		lastFocusedWindowID: "win-1",
933	}
934
935	enterVimMode(t, app, overlay1, bw1)
936	kh := bindVimModeKeyboardHandler(t, app, bw1)
937
938	app.handlePageEditableFocusChanged(context.Background(), entity.PaneID("pane-x"), true)
939
940	assert.Equal(t, input.ModeVim, kh.Mode())
941	assert.Equal(t, entity.PaneID("pane-a"), bw1.vimModePaneID)
942	assert.True(t, pv1.IsVimMode())
943}
944
945func TestVimMode_OmniboxFocusExitsVimMode(t *testing.T) {
946	f := newSingleWindowVimModeFixture(t)
947	enterVimMode(t, f.app, f.overlay1A, f.bw1)
948	kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)
949
950	expectVimModeAccentHidden(f.overlay1A)
951	f.app.handleVimModeFocusTrigger(context.Background(), f.bw1, usecase.VimModePolicyTriggerOmniboxFocus)
952
953	assert.Equal(t, input.ModeNormal, kh.Mode())
954	assert.Empty(t, f.bw1.vimModePaneID)
955}
956
957func TestVimMode_FindBarFocusExitsVimMode(t *testing.T) {
958	f := newSingleWindowVimModeFixture(t)
959	enterVimMode(t, f.app, f.overlay1A, f.bw1)
960	kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)
961
962	expectVimModeAccentHidden(f.overlay1A)
963	f.app.handleVimModeFocusTrigger(context.Background(), f.bw1, usecase.VimModePolicyTriggerFindBarFocus)
964
965	assert.Equal(t, input.ModeNormal, kh.Mode())
966	assert.Empty(t, f.bw1.vimModePaneID)
967}
968
969func TestVimMode_TabSwitchExitsAndClearsOldAccent(t *testing.T) {
970	f := newSingleWindowVimModeFixture(t)
971	enterVimMode(t, f.app, f.overlay1A, f.bw1)
972	kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)
973
974	tab2 := &entity.Tab{ID: "tab-2b", Workspace: &entity.Workspace{
975		ID:           "ws-2b",
976		ActivePaneID: "pane-c",
977		Root:         &entity.PaneNode{ID: "ws-2b-pane-c", Pane: entity.NewPane("pane-c")},
978	}}
979	f.bw1.tabs.Add(tab2)
980	f.bw1.tabs.SetActive(tab2.ID)
981	f.app.workspaceViews[tab2.ID] = setupWorkspaceViewMocks(t, f.factory)
982
983	expectVimModeAccentHidden(f.overlay1A)
984	f.app.handleVimModeTabSwitch(context.Background(), f.bw1)
985
986	assert.Equal(t, input.ModeNormal, kh.Mode())
987	assert.Empty(t, f.bw1.vimModePaneID)
988	assert.False(t, f.pv1A.IsVimMode())
989}
990
991func TestVimMode_ActivationRemainsAvailableWhenPageIsEditable(t *testing.T) {
992	f := newSingleWindowVimModeFixture(t)
993	f.app.pageEditableFocusByPane = map[entity.PaneID]bool{
994		"pane-a": true,
995	}
996
997	assert.False(t, f.app.shouldBypassVimModeActivation(f.bw1))
998}
999
1000func TestVimMode_ActivationAvailabilityDoesNotDependOnEditableFocus(t *testing.T) {
1001	f := newSingleWindowVimModeFixture(t)
1002
1003	f.app.handlePageEditableFocusChanged(context.Background(), entity.PaneID("pane-a"), true)
1004	assert.False(t, f.app.shouldBypassVimModeActivation(f.bw1))
1005
1006	f.app.handlePageEditableFocusChanged(context.Background(), entity.PaneID("pane-a"), false)
1007	assert.False(t, f.app.shouldBypassVimModeActivation(f.bw1))
1008}
1009
1010func TestVimMode_ClearEditableFocusStateRemovesStoredBypass(t *testing.T) {
1011	f := newSingleWindowVimModeFixture(t)
1012	f.app.pageEditableFocusByPane = map[entity.PaneID]bool{"pane-a": true}
1013
1014	f.app.clearPageEditableFocusState(entity.PaneID("pane-a"))
1015
1016	assert.False(t, f.app.shouldBypassVimModeActivation(f.bw1))
1017}
1018
1019// ============================================================================
1020// CSS class constants
1021// ============================================================================
1022
1023func TestVimMode_CSSClassConstants(t *testing.T) {
1024	assert.Equal(t, "vim-mode-active", component.VimModeActiveClass)
1025	assert.Equal(t, "vim-mode-pulse", component.VimModePulseClass)
1026	assert.Equal(t, "vim-mode-pulse-fast", component.VimModeFastPulseClass)
1027}
1028
1029// ============================================================================
1030// Mode toaster
1031// ============================================================================
1032
1033func newModeToasterForVimModeTest(t *testing.T, factory *mocks.MockWidgetFactory) (*component.Toaster, *mocks.MockBoxWidget, *mocks.MockLabelWidget) {
1034	t.Helper()
1035
1036	container := mocks.NewMockBoxWidget(t)
1037	label := mocks.NewMockLabelWidget(t)
1038	factory.EXPECT().NewBox(layout.OrientationHorizontal, 0).Return(container).Once()
1039	container.EXPECT().AddCssClass("toast").Once()
1040	container.EXPECT().AddCssClass("toast-info").Once()
1041	container.EXPECT().SetHalign(mock.Anything).Once()
1042	container.EXPECT().SetValign(mock.Anything).Once()
1043	container.EXPECT().SetHexpand(false).Once()
1044	container.EXPECT().SetVexpand(false).Once()
1045	container.EXPECT().SetCanTarget(false).Once()
1046	container.EXPECT().SetCanFocus(false).Once()
1047	container.EXPECT().SetVisible(false).Once()
1048	factory.EXPECT().NewLabel("").Return(label).Once()
1049	label.EXPECT().SetCanTarget(false).Once()
1050	label.EXPECT().SetCanFocus(false).Once()
1051	container.EXPECT().Append(label).Once()
1052
1053	return component.NewToaster(factory), container, label
1054}
1055
1056func expectVimModeToastShown(container *mocks.MockBoxWidget, label *mocks.MockLabelWidget) {
1057	container.EXPECT().RemoveCssClass("toast-info").Once()
1058	container.EXPECT().AddCssClass("toast-vim-mode").Once()
1059	container.EXPECT().SetHalign(mock.Anything).Once()
1060	container.EXPECT().SetValign(mock.Anything).Once()
1061	label.EXPECT().SetText("VIM MODE").Once()
1062	container.EXPECT().SetVisible(true).Once()
1063}
1064
1065func TestVimMode_ToasterRemainsVisibleUntilModeExit(t *testing.T) {
1066	factory := mocks.NewMockWidgetFactory(t)
1067	toaster, container, label := newModeToasterForVimModeTest(t, factory)
1068	expectVimModeToastShown(container, label)
1069	// These optional calls are made only by the old brief auto-dismiss path.
1070	container.EXPECT().RemoveCssClass("toast-vim-mode").Maybe()
1071	container.EXPECT().SetVisible(false).Maybe()
1072
1073	app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{
1074		UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
1075			ModeIndicatorToasterEnabled: true,
1076		}}},
1077	})}
1078	bw := &browserWindow{id: "win-1", modeToaster: toaster}
1079	app.browserWindows = map[string]*browserWindow{bw.id: bw}
1080	app.lastFocusedWindowID = bw.id
1081
1082	app.updateModeIndicatorToaster(context.Background(), bw, input.ModeVim)
1083	require.True(t, toaster.IsVisible())
1084
1085	deadline := time.Now().Add(time.Duration(component.ToastBriefDurationMs+100) * time.Millisecond)
1086	mainContext := glib.MainContextDefault()
1087	for time.Now().Before(deadline) {
1088		time.Sleep(10 * time.Millisecond)
1089		for mainContext.Pending() {
1090			mainContext.Iteration(false)
1091		}
1092	}
1093
1094	assert.True(t, toaster.IsVisible(), "Vim Mode toast must remain visible until mode exit")
1095}
1096
1097func TestVimMode_ModeExitHidesToaster(t *testing.T) {
1098	factory := mocks.NewMockWidgetFactory(t)
1099	toaster, container, label := newModeToasterForVimModeTest(t, factory)
1100	expectVimModeToastShown(container, label)
1101	container.EXPECT().RemoveCssClass("toast-custom").Once()
1102	container.EXPECT().RemoveCssClass("toast-vim-mode").Once()
1103	container.EXPECT().SetVisible(false).Once()
1104
1105	app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{
1106		UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
1107			ModeIndicatorToasterEnabled: true,
1108		}}},
1109	})}
1110	bw := &browserWindow{id: "win-1", modeToaster: toaster}
1111
1112	app.updateModeIndicatorToaster(context.Background(), bw, input.ModeVim)
1113	require.True(t, toaster.IsVisible())
1114	app.updateModeIndicatorToaster(context.Background(), bw, input.ModeNormal)
1115
1116	assert.False(t, toaster.IsVisible(), "leaving Vim Mode must hide its persistent toast")
1117}
1118
1119func TestVimMode_DisabledModeIndicatorPreferenceSuppressesToaster(t *testing.T) {
1120	factory := mocks.NewMockWidgetFactory(t)
1121	toaster, _, _ := newModeToasterForVimModeTest(t, factory)
1122	app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{})}
1123	bw := &browserWindow{id: "win-1", modeToaster: toaster}
1124
1125	app.updateModeIndicatorToaster(context.Background(), bw, input.ModeVim)
1126
1127	assert.False(t, toaster.IsVisible())
1128}
1129
1130func TestVimMode_MultiWindowToastersExitIndependently(t *testing.T) {
1131	factory := mocks.NewMockWidgetFactory(t)
1132	toaster1, container1, label1 := newModeToasterForVimModeTest(t, factory)
1133	toaster2, container2, label2 := newModeToasterForVimModeTest(t, factory)
1134	expectVimModeToastShown(container1, label1)
1135	expectVimModeToastShown(container2, label2)
1136	container1.EXPECT().RemoveCssClass("toast-custom").Once()
1137	container1.EXPECT().RemoveCssClass("toast-vim-mode").Once()
1138	container1.EXPECT().SetVisible(false).Once()
1139
1140	app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{
1141		UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
1142			ModeIndicatorToasterEnabled: true,
1143		}}},
1144	})}
1145	bw1 := &browserWindow{id: "win-1", modeToaster: toaster1}
1146	bw2 := &browserWindow{id: "win-2", modeToaster: toaster2}
1147
1148	app.updateModeIndicatorToaster(context.Background(), bw1, input.ModeVim)
1149	app.updateModeIndicatorToaster(context.Background(), bw2, input.ModeVim)
1150	app.updateModeIndicatorToaster(context.Background(), bw1, input.ModeNormal)
1151
1152	assert.False(t, toaster1.IsVisible())
1153	assert.True(t, toaster2.IsVisible(), "exiting one window must not hide another window's Vim Mode toast")
1154}
1155
1156func TestVimMode_RuntimePreferenceDisableHidesPersistentToaster(t *testing.T) {
1157	factory := mocks.NewMockWidgetFactory(t)
1158	toaster, container, label := newModeToasterForVimModeTest(t, factory)
1159	expectVimModeToastShown(container, label)
1160	container.EXPECT().RemoveCssClass("toast-custom").Once()
1161	container.EXPECT().RemoveCssClass("toast-vim-mode").Once()
1162	container.EXPECT().SetVisible(false).Once()
1163
1164	enabled := entity.RuntimeConfigSnapshot{
1165		UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
1166			ModeIndicatorToasterEnabled: true,
1167		}}},
1168	}
1169	keyboardHandler := newKeyboardHandlerInVimMode(t)
1170	bw := &browserWindow{id: "win-1", modeToaster: toaster, keyboardHandler: keyboardHandler}
1171	app := &App{
1172		runtimeConfig:  runtimeConfigStateFromSnapshotForTest(enabled),
1173		browserWindows: map[string]*browserWindow{bw.id: bw},
1174	}
1175
1176	app.updateModeIndicatorToaster(context.Background(), bw, input.ModeVim)
1177	require.True(t, toaster.IsVisible())
1178	app.applyRuntimeConfigChange(context.Background(), entity.RuntimeConfigSnapshot{})
1179
1180	assert.False(t, toaster.IsVisible(), "runtime preference disable must reconcile an existing persistent toast")
1181}
1182
1183func TestVimMode_RuntimePreferenceEnableShowsPersistentToaster(t *testing.T) {
1184	factory := mocks.NewMockWidgetFactory(t)
1185	toaster, container, label := newModeToasterForVimModeTest(t, factory)
1186	expectVimModeToastShown(container, label)
1187
1188	keyboardHandler := newKeyboardHandlerInVimMode(t)
1189	bw := &browserWindow{id: "win-1", modeToaster: toaster, keyboardHandler: keyboardHandler}
1190	app := &App{
1191		runtimeConfig:  runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{}),
1192		browserWindows: map[string]*browserWindow{bw.id: bw},
1193	}
1194	enabled := entity.RuntimeConfigSnapshot{
1195		UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
1196			ModeIndicatorToasterEnabled: true,
1197		}}},
1198	}
1199
1200	app.applyRuntimeConfigChange(context.Background(), enabled)
1201
1202	assert.True(t, toaster.IsVisible(), "runtime preference enable must reconcile active Vim Mode")
1203}
1204
1205// ============================================================================
1206// BorderManager — Vim mode must NOT use the global border overlay
1207// ============================================================================
1208
1209func TestVimMode_GlobalBorderOverlayStaysOff(t *testing.T) {
1210	mockFactory := mocks.NewMockWidgetFactory(t)
1211	mockBox := mocks.NewMockBoxWidget(t)
1212
1213	mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
1214	mockBox.EXPECT().SetCanFocus(false).Once()
1215	mockBox.EXPECT().SetCanTarget(false).Once()
1216	mockBox.EXPECT().SetHexpand(true).Once()
1217	mockBox.EXPECT().SetVexpand(true).Once()
1218	mockBox.EXPECT().SetVisible(false).Once()
1219
1220	bm := focus.NewBorderManager(mockFactory)
1221
1222	mockBox.EXPECT().SetVisible(false).Once()
1223	bm.OnModeChange(context.Background(), input.ModeNormal, input.ModeVim)
1224
1225	mockBox.EXPECT().SetVisible(false).Once()
1226	bm.OnModeChange(context.Background(), input.ModeVim, input.ModeNormal)
1227}
1228
1229// ============================================================================
1230// Pulse Debounce
1231// ============================================================================
1232
1233func TestVimMode_Pulse_DebounceSkipsRapidConsecutiveCalls(t *testing.T) {
1234	f := newSingleWindowVimModeFixture(t)
1235	enterVimMode(t, f.app, f.overlay1A, f.bw1)
1236
1237	// Set up expectations for exactly ONE normal pulse.
1238	setUpNormalPulse(f.overlay1A)
1239
1240	// First call fires the pulse (debounce timer is zero).
1241	f.app.triggerVimModePulse(context.Background(), false)
1242
1243	// Second call within the same frame should be debounced — no
1244	// mock expectations set up for it, so the test fails if it fires.
1245	f.app.triggerVimModePulse(context.Background(), false)
1246}
1247
1248func TestVimMode_Pulse_DebounceAllowsSpacedCalls(t *testing.T) {
1249	// Verify that the debounce timer starts at zero on a fresh App,
1250	// so the first pulse after entering vim mode always fires.
1251	// The exit-and-re-enter path is covered by the
1252	// TestVimMode_Pulse_DebounceResetOnClearOwnership test below.
1253
1254	f := newSingleWindowVimModeFixture(t)
1255
1256	// Fresh app: debounce timer is zero.
1257	assert.True(t, f.app.vimModePulseLastTime.IsZero(),
1258		"fresh app should have zero pulse timer")
1259
1260	enterVimMode(t, f.app, f.overlay1A, f.bw1)
1261
1262	// Pulse #1 fires because timer is zero (time.Since(zero) is huge).
1263	setUpNormalPulse(f.overlay1A)
1264	f.app.triggerVimModePulse(context.Background(), false)
1265
1266	// Timer is now set, proving the first pulse registered.
1267	assert.False(t, f.app.vimModePulseLastTime.IsZero(),
1268		"pulse timer should be non-zero after first pulse")
1269}
1270
1271func TestVimMode_Pulse_DebounceResetOnClearOwnership(t *testing.T) {
1272	// Verify that clearVimModeOwnership resets the debounce timer
1273	// so a subsequent pulse in a new vim mode session is not skipped.
1274	// We check the timer state directly rather than triggering a second
1275	// pulse (which would need pulse-cycle alternation handling).
1276
1277	f := newSingleWindowVimModeFixture(t)
1278	enterVimMode(t, f.app, f.overlay1A, f.bw1)
1279
1280	// Pulse #1 fires and sets the timer.
1281	setUpNormalPulse(f.overlay1A)
1282	f.app.triggerVimModePulse(context.Background(), false)
1283	assert.False(t, f.app.vimModePulseLastTime.IsZero(),
1284		"pulse timer should be non-zero after first pulse")
1285
1286	// Clear ownership — this resets the debounce timer.
1287	expectVimModeAccentHidden(f.overlay1A)
1288	f.app.clearVimModeOwnership(context.Background(), f.bw1)
1289	assert.Empty(t, f.bw1.vimModePaneID)
1290
1291	// Debounce timer should now be back to zero.
1292	assert.True(t, f.app.vimModePulseLastTime.IsZero(),
1293		"pulse timer should be reset after clearVimModeOwnership")
1294}
1295
1296func TestVimMode_Pulse_DebounceFirstPulseAlwaysFires(t *testing.T) {
1297	// Even if triggerVimModePulse is called rapidly on a fresh
1298	// App (e.g., first keystroke after vim mode entry), the first
1299	// call must always fire. This is guaranteed by the zero value
1300	// of time.Time producing a time.Since result much larger than
1301	// the debounce interval.
1302	f := newSingleWindowVimModeFixture(t)
1303	enterVimMode(t, f.app, f.overlay1A, f.bw1)
1304
1305	// Rapid double pulse — only first should fire regardless of order.
1306	// Set up just one set of expectations.
1307	setUpNormalPulse(f.overlay1A)
1308	f.app.triggerVimModePulse(context.Background(), false)
1309	f.app.triggerVimModePulse(context.Background(), false)
1310}
1311
1312// ============================================================================
1313// UI Scale: Vim mode CSS uses relative (em) units
1314// ============================================================================
1315
1316func TestVimMode_CSSUsesEms(t *testing.T) {
1317	css := theme.GenerateCSS(theme.DefaultDarkPalette())
1318
1319	assert.Contains(t, css, "box-shadow: inset 0 0 0 0.125em",
1320		"vim mode overlay box-shadow should use em for scaling")
1321	assert.Contains(t, css, "0.2em",
1322		"vim mode overlay pulse glow radius uses em")
1323	assert.Contains(t, css, "0.26em",
1324		"vim mode overlay fast-pulse glow radius uses em")
1325	assert.NotContains(t, css, "vim-mode-indicator",
1326		"removed PAGE label styling must not remain")
1327}
1328
1329func TestVimMode_CSSPulseTimingUsesDefaultTransitionDuration(t *testing.T) {
1330	// The pulse animation durations should be derived from the
1331	// default transition duration (normal = 3x, fast = 6x).
1332	// Default = 120ms → normal = 360ms, fast = 720ms.
1333	css := theme.GenerateCSS(theme.DefaultDarkPalette())
1334
1335	assert.Contains(t, css, "360ms",
1336		"normal pulse duration should be 3x transition duration (360ms for 120ms base)")
1337	assert.Contains(t, css, "720ms",
1338		"fast pulse duration should be 6x transition duration (720ms for 120ms base)")
1339}
1340
1341func TestVimMode_CSSWithCustomTransitionDuration(t *testing.T) {
1342	// Custom transition duration (e.g., 200ms) should produce
1343	// adjusted pulse timings: normal = 600ms, fast = 1200ms.
1344	css := theme.GenerateCSSFullWithTiming(
1345		theme.DefaultDarkPalette(),
1346		1.0,
1347		theme.DefaultFontConfig(),
1348		theme.DefaultModeColors(),
1349		200,
1350	)
1351
1352	assert.Contains(t, css, "600ms",
1353		"normal pulse should be 3x custom transition duration (200ms -> 600ms)")
1354	assert.Contains(t, css, "1200ms",
1355		"fast pulse should be 6x custom transition duration (200ms -> 1200ms)")
1356}

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