v0.31.0 bf0756b

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

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