v0.33.1 9577fee

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

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