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 newModeToasterForVimModeTest(t *testing.T, factory *mocks.MockWidgetFactory) (*component.Toaster, *mocks.MockBoxWidget, *mocks.MockLabelWidget) {
1023	t.Helper()
1024
1025	container := mocks.NewMockBoxWidget(t)
1026	label := mocks.NewMockLabelWidget(t)
1027	factory.EXPECT().NewBox(layout.OrientationHorizontal, 0).Return(container).Once()
1028	container.EXPECT().AddCssClass("toast").Once()
1029	container.EXPECT().AddCssClass("toast-info").Once()
1030	container.EXPECT().SetHalign(mock.Anything).Once()
1031	container.EXPECT().SetValign(mock.Anything).Once()
1032	container.EXPECT().SetHexpand(false).Once()
1033	container.EXPECT().SetVexpand(false).Once()
1034	container.EXPECT().SetCanTarget(false).Once()
1035	container.EXPECT().SetCanFocus(false).Once()
1036	container.EXPECT().SetVisible(false).Once()
1037	factory.EXPECT().NewLabel("").Return(label).Once()
1038	label.EXPECT().SetCanTarget(false).Once()
1039	label.EXPECT().SetCanFocus(false).Once()
1040	container.EXPECT().Append(label).Once()
1041
1042	return component.NewToaster(factory), container, label
1043}
1044
1045func expectVimModeToastShown(container *mocks.MockBoxWidget, label *mocks.MockLabelWidget) {
1046	container.EXPECT().RemoveCssClass("toast-info").Once()
1047	container.EXPECT().AddCssClass("toast-vim-mode").Once()
1048	container.EXPECT().SetHalign(mock.Anything).Once()
1049	container.EXPECT().SetValign(mock.Anything).Once()
1050	label.EXPECT().SetText("VIM MODE").Once()
1051	container.EXPECT().SetVisible(true).Once()
1052}
1053
1054func TestVimMode_ToasterRemainsVisibleUntilModeExit(t *testing.T) {
1055	factory := mocks.NewMockWidgetFactory(t)
1056	toaster, container, label := newModeToasterForVimModeTest(t, factory)
1057	expectVimModeToastShown(container, label)
1058	// These optional calls are made only by the old brief auto-dismiss path.
1059	container.EXPECT().RemoveCssClass("toast-vim-mode").Maybe()
1060	container.EXPECT().SetVisible(false).Maybe()
1061
1062	app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{
1063		UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
1064			ModeIndicatorToasterEnabled: true,
1065		}}},
1066	})}
1067	bw := &browserWindow{id: "win-1", modeToaster: toaster}
1068	app.browserWindows = map[string]*browserWindow{bw.id: bw}
1069	app.lastFocusedWindowID = bw.id
1070
1071	app.updateModeIndicatorToaster(context.Background(), bw, input.ModeVim)
1072	require.True(t, toaster.IsVisible())
1073
1074	deadline := time.Now().Add(time.Duration(component.ToastBriefDurationMs+100) * time.Millisecond)
1075	mainContext := glib.MainContextDefault()
1076	for time.Now().Before(deadline) {
1077		time.Sleep(10 * time.Millisecond)
1078		for mainContext.Pending() {
1079			mainContext.Iteration(false)
1080		}
1081	}
1082
1083	assert.True(t, toaster.IsVisible(), "Vim Mode toast must remain visible until mode exit")
1084}
1085
1086func TestVimMode_ModeExitHidesToaster(t *testing.T) {
1087	factory := mocks.NewMockWidgetFactory(t)
1088	toaster, container, label := newModeToasterForVimModeTest(t, factory)
1089	expectVimModeToastShown(container, label)
1090	container.EXPECT().RemoveCssClass("toast-custom").Once()
1091	container.EXPECT().RemoveCssClass("toast-vim-mode").Once()
1092	container.EXPECT().SetVisible(false).Once()
1093
1094	app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{
1095		UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
1096			ModeIndicatorToasterEnabled: true,
1097		}}},
1098	})}
1099	bw := &browserWindow{id: "win-1", modeToaster: toaster}
1100
1101	app.updateModeIndicatorToaster(context.Background(), bw, input.ModeVim)
1102	require.True(t, toaster.IsVisible())
1103	app.updateModeIndicatorToaster(context.Background(), bw, input.ModeNormal)
1104
1105	assert.False(t, toaster.IsVisible(), "leaving Vim Mode must hide its persistent toast")
1106}
1107
1108func TestVimMode_DisabledModeIndicatorPreferenceSuppressesToaster(t *testing.T) {
1109	factory := mocks.NewMockWidgetFactory(t)
1110	toaster, _, _ := newModeToasterForVimModeTest(t, factory)
1111	app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{})}
1112	bw := &browserWindow{id: "win-1", modeToaster: toaster}
1113
1114	app.updateModeIndicatorToaster(context.Background(), bw, input.ModeVim)
1115
1116	assert.False(t, toaster.IsVisible())
1117}
1118
1119func TestVimMode_MultiWindowToastersExitIndependently(t *testing.T) {
1120	factory := mocks.NewMockWidgetFactory(t)
1121	toaster1, container1, label1 := newModeToasterForVimModeTest(t, factory)
1122	toaster2, container2, label2 := newModeToasterForVimModeTest(t, factory)
1123	expectVimModeToastShown(container1, label1)
1124	expectVimModeToastShown(container2, label2)
1125	container1.EXPECT().RemoveCssClass("toast-custom").Once()
1126	container1.EXPECT().RemoveCssClass("toast-vim-mode").Once()
1127	container1.EXPECT().SetVisible(false).Once()
1128
1129	app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{
1130		UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
1131			ModeIndicatorToasterEnabled: true,
1132		}}},
1133	})}
1134	bw1 := &browserWindow{id: "win-1", modeToaster: toaster1}
1135	bw2 := &browserWindow{id: "win-2", modeToaster: toaster2}
1136
1137	app.updateModeIndicatorToaster(context.Background(), bw1, input.ModeVim)
1138	app.updateModeIndicatorToaster(context.Background(), bw2, input.ModeVim)
1139	app.updateModeIndicatorToaster(context.Background(), bw1, input.ModeNormal)
1140
1141	assert.False(t, toaster1.IsVisible())
1142	assert.True(t, toaster2.IsVisible(), "exiting one window must not hide another window's Vim Mode toast")
1143}
1144
1145func TestVimMode_RuntimePreferenceDisableHidesPersistentToaster(t *testing.T) {
1146	factory := mocks.NewMockWidgetFactory(t)
1147	toaster, container, label := newModeToasterForVimModeTest(t, factory)
1148	expectVimModeToastShown(container, label)
1149	container.EXPECT().RemoveCssClass("toast-custom").Once()
1150	container.EXPECT().RemoveCssClass("toast-vim-mode").Once()
1151	container.EXPECT().SetVisible(false).Once()
1152
1153	enabled := entity.RuntimeConfigSnapshot{
1154		UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
1155			ModeIndicatorToasterEnabled: true,
1156		}}},
1157	}
1158	keyboardHandler := newKeyboardHandlerInVimMode(t)
1159	bw := &browserWindow{id: "win-1", modeToaster: toaster, keyboardHandler: keyboardHandler}
1160	app := &App{
1161		runtimeConfig:  runtimeConfigStateFromSnapshotForTest(enabled),
1162		browserWindows: map[string]*browserWindow{bw.id: bw},
1163	}
1164
1165	app.updateModeIndicatorToaster(context.Background(), bw, input.ModeVim)
1166	require.True(t, toaster.IsVisible())
1167	app.applyRuntimeConfigChange(context.Background(), entity.RuntimeConfigSnapshot{})
1168
1169	assert.False(t, toaster.IsVisible(), "runtime preference disable must reconcile an existing persistent toast")
1170}
1171
1172func TestVimMode_RuntimePreferenceEnableShowsPersistentToaster(t *testing.T) {
1173	factory := mocks.NewMockWidgetFactory(t)
1174	toaster, container, label := newModeToasterForVimModeTest(t, factory)
1175	expectVimModeToastShown(container, label)
1176
1177	keyboardHandler := newKeyboardHandlerInVimMode(t)
1178	bw := &browserWindow{id: "win-1", modeToaster: toaster, keyboardHandler: keyboardHandler}
1179	app := &App{
1180		runtimeConfig:  runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{}),
1181		browserWindows: map[string]*browserWindow{bw.id: bw},
1182	}
1183	enabled := entity.RuntimeConfigSnapshot{
1184		UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
1185			ModeIndicatorToasterEnabled: true,
1186		}}},
1187	}
1188
1189	app.applyRuntimeConfigChange(context.Background(), enabled)
1190
1191	assert.True(t, toaster.IsVisible(), "runtime preference enable must reconcile active Vim Mode")
1192}
1193
1194// ============================================================================
1195// Pulse Debounce
1196// ============================================================================
1197
1198func TestVimMode_Pulse_DebounceSkipsRapidConsecutiveCalls(t *testing.T) {
1199	f := newSingleWindowVimModeFixture(t)
1200	enterVimMode(t, f.app, f.bw1)
1201
1202	// Set up expectations for exactly ONE normal pulse.
1203	attachTestModeFrame(f.bw1, f.overlay1A)
1204	setUpNormalPulse(f.overlay1A)
1205
1206	// First call fires the pulse (debounce timer is zero).
1207	f.app.triggerVimModePulse(context.Background(), false)
1208
1209	// Second call within the same frame should be debounced — no
1210	// mock expectations set up for it, so the test fails if it fires.
1211	f.app.triggerVimModePulse(context.Background(), false)
1212}
1213
1214func TestVimMode_Pulse_DebounceAllowsSpacedCalls(t *testing.T) {
1215	// Verify that the debounce timer starts at zero on a fresh App,
1216	// so the first pulse after entering vim mode always fires.
1217	// The exit-and-re-enter path is covered by the
1218	// TestVimMode_Pulse_DebounceResetOnClearOwnership test below.
1219
1220	f := newSingleWindowVimModeFixture(t)
1221
1222	// Fresh app: debounce timer is zero.
1223	assert.True(t, f.app.vimMode.pulseIdle(),
1224		"fresh app should have zero pulse timer")
1225
1226	enterVimMode(t, f.app, f.bw1)
1227
1228	// Pulse #1 fires because timer is zero (time.Since(zero) is huge).
1229	attachTestModeFrame(f.bw1, f.overlay1A)
1230	setUpNormalPulse(f.overlay1A)
1231	f.app.triggerVimModePulse(context.Background(), false)
1232
1233	// Timer is now set, proving the first pulse registered.
1234	assert.False(t, f.app.vimMode.pulseIdle(),
1235		"pulse timer should be non-zero after first pulse")
1236}
1237
1238func TestVimMode_Pulse_DebounceResetOnClearOwnership(t *testing.T) {
1239	// Verify that clearVimModeOwnership resets the debounce timer
1240	// so a subsequent pulse in a new vim mode session is not skipped.
1241	// We check the timer state directly rather than triggering a second
1242	// pulse (which would need pulse-cycle alternation handling).
1243
1244	f := newSingleWindowVimModeFixture(t)
1245	enterVimMode(t, f.app, f.bw1)
1246
1247	// Pulse #1 fires and sets the timer.
1248	attachTestModeFrame(f.bw1, f.overlay1A)
1249	setUpNormalPulse(f.overlay1A)
1250	f.app.triggerVimModePulse(context.Background(), false)
1251	assert.False(t, f.app.vimMode.pulseIdle(),
1252		"pulse timer should be non-zero after first pulse")
1253
1254	// Clear ownership — this resets the debounce timer.
1255	expectVimModeAccentHidden(f.overlay1A)
1256	f.app.clearVimModeOwnership(context.Background(), f.bw1)
1257	assert.Empty(t, f.bw1.vimModePaneID)
1258
1259	// Debounce timer should now be back to zero.
1260	assert.True(t, f.app.vimMode.pulseIdle(),
1261		"pulse timer should be reset after clearVimModeOwnership")
1262}
1263
1264func TestVimMode_Pulse_DebounceFirstPulseAlwaysFires(t *testing.T) {
1265	// Even if triggerVimModePulse is called rapidly on a fresh
1266	// App (e.g., first keystroke after vim mode entry), the first
1267	// call must always fire. This is guaranteed by the zero value
1268	// of time.Time producing a time.Since result much larger than
1269	// the debounce interval.
1270	f := newSingleWindowVimModeFixture(t)
1271	enterVimMode(t, f.app, f.bw1)
1272
1273	// Rapid double pulse — only first should fire regardless of order.
1274	// Set up just one set of expectations.
1275	attachTestModeFrame(f.bw1, f.overlay1A)
1276	setUpNormalPulse(f.overlay1A)
1277	f.app.triggerVimModePulse(context.Background(), false)
1278	f.app.triggerVimModePulse(context.Background(), false)
1279}
1280
1281// ============================================================================
1282// UI Scale: Vim mode CSS uses relative (em) units
1283// ============================================================================
1284
1285func TestVimMode_CSSUsesEms(t *testing.T) {
1286	css := theme.GenerateCSS(theme.DefaultDarkPalette())
1287
1288	assert.Contains(t, css, "box-shadow: inset 0 0 0 0.125em",
1289		"vim mode overlay box-shadow should use em for scaling")
1290	assert.Contains(t, css, "0.2em",
1291		"vim mode overlay pulse glow radius uses em")
1292	assert.Contains(t, css, "0.26em",
1293		"vim mode overlay fast-pulse glow radius uses em")
1294	assert.NotContains(t, css, "vim-mode-indicator",
1295		"removed PAGE label styling must not remain")
1296}
1297
1298func TestVimMode_CSSPulseTimingUsesDefaultTransitionDuration(t *testing.T) {
1299	// The pulse animation durations should be derived from the
1300	// default transition duration (normal = 3x, fast = 6x).
1301	// Default = 120ms → normal = 360ms, fast = 720ms.
1302	css := theme.GenerateCSS(theme.DefaultDarkPalette())
1303
1304	assert.Contains(t, css, "360ms",
1305		"normal pulse duration should be 3x transition duration (360ms for 120ms base)")
1306	assert.Contains(t, css, "720ms",
1307		"fast pulse duration should be 6x transition duration (720ms for 120ms base)")
1308}
1309
1310func TestVimMode_CSSWithCustomTransitionDuration(t *testing.T) {
1311	// Custom transition duration (e.g., 200ms) should produce
1312	// adjusted pulse timings: normal = 600ms, fast = 1200ms.
1313	css := theme.GenerateCSSFullWithTiming(
1314		theme.DefaultDarkPalette(),
1315		1.0,
1316		theme.DefaultFontConfig(),
1317		theme.DefaultModeColors(),
1318		200,
1319	)
1320
1321	assert.Contains(t, css, "600ms",
1322		"normal pulse should be 3x custom transition duration (200ms -> 600ms)")
1323	assert.Contains(t, css, "1200ms",
1324		"fast pulse should be 6x custom transition duration (200ms -> 1200ms)")
1325}

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