detached 5041353

keyboard_test.go RawHistoryFolder

1package dispatcher
2
3import (
4	"context"
5	"fmt"
6	"testing"
7
8	"github.com/bnema/dumber/internal/application/port"
9	"github.com/bnema/dumber/internal/application/port/mocks"
10	"github.com/bnema/dumber/internal/application/usecase"
11	"github.com/bnema/dumber/internal/domain/entity"
12	"github.com/bnema/dumber/internal/ui/coordinator"
13	"github.com/bnema/dumber/internal/ui/input"
14	"github.com/stretchr/testify/assert"
15	"github.com/stretchr/testify/require"
16)
17
18func TestKeyboardDispatcher_TabActionsUseInjectedKeyboardActions(t *testing.T) {
19	ctx := context.Background()
20	called := make([]string, 0)
21	var switchedIndex int
22	d := NewKeyboardDispatcher(
23		ctx,
24		&coordinator.WorkspaceCoordinator{},
25		&coordinator.NavigationCoordinator{},
26		nil,
27		nil,
28		KeyboardActions{
29			NewTab: func(context.Context) error {
30				called = append(called, "new")
31				return nil
32			},
33			CloseTab: func(context.Context) error {
34				called = append(called, "close")
35				return nil
36			},
37			NextTab: func(context.Context) error {
38				called = append(called, "next")
39				return nil
40			},
41			PreviousTab: func(context.Context) error {
42				called = append(called, "previous")
43				return nil
44			},
45			SwitchLastTab: func(context.Context) error {
46				called = append(called, "last")
47				return nil
48			},
49			SwitchTabIndex: func(_ context.Context, index int) error {
50				called = append(called, "index")
51				switchedIndex = index
52				return nil
53			},
54		},
55		func(context.Context) entity.PaneID { return "" },
56	)
57
58	require.NoError(t, d.Dispatch(ctx, input.ActionNewTab))
59	require.NoError(t, d.Dispatch(ctx, input.ActionCloseTab))
60	require.NoError(t, d.Dispatch(ctx, input.ActionNextTab))
61	require.NoError(t, d.Dispatch(ctx, input.ActionPreviousTab))
62	require.NoError(t, d.Dispatch(ctx, input.ActionSwitchLastTab))
63	require.NoError(t, d.Dispatch(ctx, input.ActionSwitchTabIndex5))
64
65	assert.Equal(t, []string{"new", "close", "next", "previous", "last", "index"}, called)
66	assert.Equal(t, 4, switchedIndex)
67}
68
69func TestKeyboardDispatcher_ToggleHistorySidebarCallsCallback(t *testing.T) {
70	ctx := context.Background()
71	d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, KeyboardActions{}, func(context.Context) entity.PaneID { return "" })
72
73	var called bool
74	d.SetOnToggleHistorySidebar(func(context.Context) error {
75		called = true
76		return nil
77	})
78
79	err := d.Dispatch(ctx, input.ActionToggleHistorySystemView)
80	require.NoError(t, err)
81	assert.True(t, called, "onToggleHistorySidebar should have been called")
82}
83
84func TestKeyboardDispatcher_ToggleHistorySystemViewReturnsErrorWhenHandlerMissing(t *testing.T) {
85	ctx := context.Background()
86	d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, KeyboardActions{}, func(context.Context) entity.PaneID { return "" })
87
88	err := d.Dispatch(ctx, input.ActionToggleHistorySystemView)
89	require.Error(t, err)
90	assert.ErrorContains(t, err, "history sidebar unavailable")
91}
92
93func TestKeyboardDispatcher_ToggleHistorySidebarErrorPropagation(t *testing.T) {
94	ctx := context.Background()
95	d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, KeyboardActions{}, func(context.Context) entity.PaneID { return "" })
96
97	wantErr := fmt.Errorf("sidebar toggle failed")
98	d.SetOnToggleHistorySidebar(func(context.Context) error {
99		return wantErr
100	})
101
102	err := d.Dispatch(ctx, input.ActionToggleHistorySystemView)
103	require.Error(t, err)
104	assert.ErrorIs(t, err, wantErr, "onToggleHistorySidebar error should propagate")
105}
106
107func TestKeyboardDispatcher_ToggleHistorySidebarSetThenUnsetReturnsError(t *testing.T) {
108	ctx := context.Background()
109	d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, KeyboardActions{}, func(context.Context) entity.PaneID { return "" })
110
111	d.SetOnToggleHistorySidebar(func(context.Context) error {
112		return nil
113	})
114	d.SetOnToggleHistorySidebar(nil)
115
116	err := d.Dispatch(ctx, input.ActionToggleHistorySystemView)
117	require.Error(t, err)
118	assert.ErrorContains(t, err, "history sidebar unavailable")
119}
120
121func TestKeyboardDispatcher_ToggleFavoritesSidebarCallsCallbackAndPropagatesErrors(t *testing.T) {
122	ctx := context.Background()
123	d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, KeyboardActions{}, func(context.Context) entity.PaneID { return "" })
124
125	missingErr := d.Dispatch(ctx, input.ActionToggleFavoritesSidebar)
126	require.Error(t, missingErr)
127	require.ErrorContains(t, missingErr, "favorites sidebar unavailable")
128
129	wantErr := fmt.Errorf("favorites failed")
130	d.SetOnToggleFavoritesSidebar(func(context.Context) error { return wantErr })
131	err := d.Dispatch(ctx, input.ActionToggleFavoritesSidebar)
132	require.Error(t, err)
133	require.ErrorIs(t, err, wantErr)
134
135	var called bool
136	d.SetOnToggleFavoritesSidebar(func(context.Context) error { called = true; return nil })
137	require.NoError(t, d.Dispatch(ctx, input.ActionToggleFavoritesSidebar))
138	assert.True(t, called)
139}
140
141func TestKeyboardDispatcher_ToggleCurrentPageFavoriteCallsCallbackAndPropagatesErrors(t *testing.T) {
142	ctx := context.Background()
143	d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, KeyboardActions{}, func(context.Context) entity.PaneID { return "" })
144
145	missingErr := d.Dispatch(ctx, input.ActionToggleCurrentPageFavorite)
146	require.Error(t, missingErr)
147	require.ErrorContains(t, missingErr, "toggle current page handler not wired")
148
149	wantErr := fmt.Errorf("toggle failed")
150	d.SetOnToggleCurrentPageFavorite(func(context.Context) error { return wantErr })
151	err := d.Dispatch(ctx, input.ActionToggleCurrentPageFavorite)
152	require.Error(t, err)
153	require.ErrorIs(t, err, wantErr)
154
155	var called bool
156	d.SetOnToggleCurrentPageFavorite(func(context.Context) error { called = true; return nil })
157	require.NoError(t, d.Dispatch(ctx, input.ActionToggleCurrentPageFavorite))
158	assert.True(t, called)
159}
160
161func TestKeyboardDispatcher_PassesActivePaneIDToShellCallbacks(t *testing.T) {
162	ctx := context.Background()
163	activePaneID := entity.PaneID("pane-1")
164	d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, KeyboardActions{}, func(context.Context) entity.PaneID {
165		return activePaneID
166	})
167
168	tests := []struct {
169		name   string
170		set    func(func(context.Context, entity.PaneID) error)
171		invoke func(context.Context) error
172	}{
173		{
174			name:   "session open",
175			set:    d.SetOnSessionOpen,
176			invoke: d.handleSessionOpen,
177		},
178		{
179			name:   "move pane to tab",
180			set:    d.SetOnMovePaneToTab,
181			invoke: d.handleMovePaneToTab,
182		},
183		{
184			name:   "move pane to next tab",
185			set:    d.SetOnMovePaneToNextTab,
186			invoke: d.handleMovePaneToNextTab,
187		},
188		{
189			name:   "eject pane to window",
190			set:    d.SetOnEjectPaneToWindow,
191			invoke: d.handleEjectPaneToWindow,
192		},
193	}
194
195	for _, tc := range tests {
196		t.Run(tc.name, func(t *testing.T) {
197			var gotPaneID entity.PaneID
198			tc.set(func(_ context.Context, paneID entity.PaneID) error {
199				gotPaneID = paneID
200				return nil
201			})
202
203			require.NoError(t, tc.invoke(ctx))
204			assert.Equal(t, activePaneID, gotPaneID)
205		})
206	}
207}
208
209type mockScrollableWebView struct {
210	*mocks.MockWebView
211	*mocks.MockPageScrollable
212}
213
214type lifecycleScrollableWebView struct {
215	*mockScrollableWebView
216	cancelCalls int
217}
218
219func (wv *lifecycleScrollableWebView) CancelPageScroll(context.Context) {
220	wv.cancelCalls++
221}
222
223func TestKeyboardDispatcher_VimModeActionsRouteToCorrectScrollCommand(t *testing.T) {
224	ctx := context.Background()
225
226	tests := []struct {
227		action     input.Action
228		command    port.PageScrollCommand
229		expectedDx int
230		expectedDy int
231	}{
232		{input.ActionVimScrollLeft, port.PageScrollCommandLeft, -80, 0},
233		{input.ActionVimScrollRight, port.PageScrollCommandRight, 80, 0},
234		{input.ActionVimScrollUp, port.PageScrollCommandUp, 0, -80},
235		{input.ActionVimScrollDown, port.PageScrollCommandDown, 0, 80},
236		{input.ActionVimScrollUpFast, port.PageScrollCommandUpFast, 0, -320},
237		{input.ActionVimScrollDownFast, port.PageScrollCommandDownFast, 0, 320},
238	}
239
240	for _, tc := range tests {
241		t.Run(string(tc.action), func(t *testing.T) {
242			base := mocks.NewMockWebView(t)
243			scroller := mocks.NewMockPageScrollable(t)
244			wv := &mockScrollableWebView{MockWebView: base, MockPageScrollable: scroller}
245
246			navCoord := &coordinator.NavigationCoordinator{}
247			navCoord.SetPageScrollUseCase(usecase.NewPageScrollUseCase())
248
249			d := NewKeyboardDispatcher(
250				ctx,
251				&coordinator.WorkspaceCoordinator{},
252				navCoord,
253				nil,
254				nil,
255				KeyboardActions{
256					ActiveWebView: func(context.Context) port.WebView { return wv },
257				},
258				func(context.Context) entity.PaneID { return "" },
259			)
260
261			base.EXPECT().ID().Return(port.WebViewID(42)).Once()
262			req := port.PageScrollRequest{Command: tc.command, FallbackDX: tc.expectedDx, FallbackDY: tc.expectedDy}
263			scroller.EXPECT().ScrollPage(ctx, req).Return(nil).Once()
264
265			err := d.Dispatch(ctx, tc.action)
266			require.NoError(t, err)
267		})
268	}
269}
270
271func TestKeyboardDispatcher_VimScrollLifecycleRoutesContinuousAndStop(t *testing.T) {
272	ctx := context.Background()
273	base := mocks.NewMockWebView(t)
274	scroller := mocks.NewMockPageScrollable(t)
275	wv := &lifecycleScrollableWebView{mockScrollableWebView: &mockScrollableWebView{MockWebView: base, MockPageScrollable: scroller}}
276	navCoord := &coordinator.NavigationCoordinator{}
277	navCoord.SetPageScrollUseCase(usecase.NewPageScrollUseCase())
278	d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, navCoord, nil, nil, KeyboardActions{
279		ActiveWebView: func(context.Context) port.WebView { return wv },
280	}, func(context.Context) entity.PaneID { return "" })
281
282	scroller.EXPECT().ScrollPage(ctx, port.PageScrollRequest{
283		Command: port.PageScrollCommandDown, FallbackDY: 80, Continuous: true,
284	}).Return(nil).Once()
285	require.NoError(t, d.DispatchVimScrollLifecycle(ctx, input.ActionVimScrollDown, input.VimScrollContinuous))
286	require.NoError(t, d.DispatchVimScrollLifecycle(ctx, input.ActionVimScrollDown, input.VimScrollStop))
287	assert.Equal(t, 1, wv.cancelCalls)
288}
289
290func TestKeyboardDispatcher_VimModeNoopWhenNoActiveWebView(t *testing.T) {
291	ctx := context.Background()
292
293	navCoord := &coordinator.NavigationCoordinator{}
294	navCoord.SetPageScrollUseCase(usecase.NewPageScrollUseCase())
295
296	d := NewKeyboardDispatcher(
297		ctx,
298		&coordinator.WorkspaceCoordinator{},
299		navCoord,
300		nil,
301		nil,
302		KeyboardActions{},
303		func(context.Context) entity.PaneID { return "" },
304	)
305
306	// No ActiveWebView set — dispatcher should no-op cleanly
307	err := d.Dispatch(ctx, input.ActionVimScrollDown)
308	require.NoError(t, err)
309}
310
311func TestKeyboardDispatcher_VimModeNoopWhenActiveWebViewReturnsNil(t *testing.T) {
312	ctx := context.Background()
313
314	navCoord := &coordinator.NavigationCoordinator{}
315	navCoord.SetPageScrollUseCase(usecase.NewPageScrollUseCase())
316
317	d := NewKeyboardDispatcher(
318		ctx,
319		&coordinator.WorkspaceCoordinator{},
320		navCoord,
321		nil,
322		nil,
323		KeyboardActions{
324			ActiveWebView: func(context.Context) port.WebView { return nil },
325		},
326		func(context.Context) entity.PaneID { return "" },
327	)
328
329	err := d.Dispatch(ctx, input.ActionVimScrollDown)
330	require.NoError(t, err)
331}
332
333func TestVimScrollSpec_IsSingleSourceForCommandAndPulse(t *testing.T) {
334	tests := []struct {
335		action input.Action
336		cmd    usecase.PageScrollCommand
337		fast   bool
338	}{
339		{input.ActionVimScrollLeft, usecase.PageScrollLeft, false},
340		{input.ActionVimScrollRight, usecase.PageScrollRight, false},
341		{input.ActionVimScrollUp, usecase.PageScrollUp, false},
342		{input.ActionVimScrollDown, usecase.PageScrollDown, false},
343		{input.ActionVimScrollUpFast, usecase.PageScrollUpFast, true},
344		{input.ActionVimScrollDownFast, usecase.PageScrollDownFast, true},
345	}
346	for _, tc := range tests {
347		spec, ok := vimScrollSpec(tc.action)
348		require.True(t, ok, string(tc.action))
349		assert.Equal(t, tc.cmd, spec.cmd)
350		assert.Equal(t, tc.fast, spec.fast)
351		cmd, ok := vimScrollCommand(tc.action)
352		require.True(t, ok)
353		assert.Equal(t, tc.cmd, cmd)
354	}
355	_, ok := vimScrollSpec(input.ActionQuit)
356	assert.False(t, ok)
357}
358
359func TestKeyboardDispatcher_VimScrollTapUsesSharedSpecPulse(t *testing.T) {
360	ctx := context.Background()
361	base := mocks.NewMockWebView(t)
362	scroller := mocks.NewMockPageScrollable(t)
363	wv := &mockScrollableWebView{MockWebView: base, MockPageScrollable: scroller}
364	navCoord := &coordinator.NavigationCoordinator{}
365	navCoord.SetPageScrollUseCase(usecase.NewPageScrollUseCase())
366	d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, navCoord, nil, nil, KeyboardActions{
367		ActiveWebView: func(context.Context) port.WebView { return wv },
368	}, func(context.Context) entity.PaneID { return "" })
369
370	var pulses []bool
371	d.SetOnVimModePulse(func(_ context.Context, fast bool) {
372		pulses = append(pulses, fast)
373	})
374
375	base.EXPECT().ID().Return(port.WebViewID(7)).Twice()
376	scroller.EXPECT().ScrollPage(ctx, port.PageScrollRequest{
377		Command: port.PageScrollCommandDown, FallbackDY: 80,
378	}).Return(nil).Once()
379	scroller.EXPECT().ScrollPage(ctx, port.PageScrollRequest{
380		Command: port.PageScrollCommandDownFast, FallbackDY: 320,
381	}).Return(nil).Once()
382
383	require.NoError(t, d.Dispatch(ctx, input.ActionVimScrollDown))
384	require.NoError(t, d.Dispatch(ctx, input.ActionVimScrollDownFast))
385	assert.Equal(t, []bool{false, true}, pulses)
386}

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