main 5041353

app_vim_page_test.go RawHistoryFolder

1package ui
2
3import (
4	"context"
5	"testing"
6	"time"
7
8	"github.com/stretchr/testify/require"
9
10	"github.com/bnema/dumber/internal/application/dto"
11	portmocks "github.com/bnema/dumber/internal/application/port/mocks"
12	"github.com/bnema/dumber/internal/domain/entity"
13	"github.com/bnema/dumber/internal/ui/input"
14)
15
16// newVimPageTestWindow builds a window in Vim Mode with a mode toaster bound
17// to the pending-sequence callback, as initChrome does. It records every header
18// the pill shows so tests can assert what the user sees.
19func newVimPageTestWindow(t *testing.T) (*App, *browserWindow, *[]string) {
20	t.Helper()
21	app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{
22		UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
23			ModeIndicatorToasterEnabled: true,
24			ModeLegend:                  "off",
25		}}},
26	})}
27	bw, view := newFakeModeToasterWindow("win-1")
28	app.browserWindows = map[string]*browserWindow{bw.id: bw}
29	bw.keyboardHandler = newKeyboardHandlerInVimMode(t)
30	app.bindVimModeSequenceToaster(context.Background(), bw)
31	app.updateModeFrame(bw, input.ModeVim)
32	return app, bw, &view.headers
33}
34
35var visualOutcome = hintsOutcome(dto.VimPageVisual)
36
37func hintsOutcome(kind dto.VimPageInteractionKind) dto.VimNavigationOutcome {
38	return dto.VimNavigationOutcome{CapturePageKeys: true, PageKind: kind, PageMode: dto.VimPageModeHints}
39}
40
41// Starting a key-capturing interaction must leave the sub-mode label on the
42// toast. The capture path used to reset the pending sequence with
43// notification, which repainted the toast back to plain "VIM MODE" in the
44// same instant and made the interaction look as if it had ended.
45func TestCaptureVimPageKeysKeepsSubModeLabelOnToast(t *testing.T) {
46	app, bw, shown := newVimPageTestWindow(t)
47	wv := portmocks.NewMockWebView(t)
48
49	app.captureVimPageKeys(context.Background(), bw, wv, visualOutcome)
50
51	require.NotEmpty(t, *shown)
52	require.Equal(t, "VIM MODE · HINTS", (*shown)[len(*shown)-1], "every interaction opens on hints")
53	require.True(t, bw.keyboardHandler.PageKeyCaptureActive())
54}
55
56// A completed sequence is reported with an empty pending string before its
57// action runs. When that action starts a page interaction, the stale pending
58// notification must not reach the toast after the sub-mode label.
59func TestVimPageSubModeLabelSurvivesSequenceCompletion(t *testing.T) {
60	app, bw, shown := newVimPageTestWindow(t)
61	wv := portmocks.NewMockWebView(t)
62
63	app.showPendingSequence(context.Background(), bw, "")
64	app.captureVimPageKeys(context.Background(), bw, wv, hintsOutcome(dto.VimPageHintFollow))
65	// Any pending notification emitted while the page owns the keys is noise.
66	app.showPendingSequence(context.Background(), bw, "")
67
68	require.Equal(t, "VIM MODE · HINTS", (*shown)[len(*shown)-1])
69}
70
71// The legend is a key reference for Vim Mode bindings; while the page owns the
72// keys it would describe keys that do not apply, so it must be hidden and
73// restored when the interaction ends.
74func TestVimPageInteractionHidesLegendWhileCaptured(t *testing.T) {
75	app, bw, _ := newVimPageTestWindow(t)
76	toaster := bw.modeToaster
77	wv := portmocks.NewMockWebView(t)
78
79	app.captureVimPageKeys(context.Background(), bw, wv, visualOutcome)
80	require.True(t, toaster.suspended, "legend must be suspended while the page captures keys")
81
82	wv.EXPECT().ID().Return(1).Maybe()
83	app.endVimPageInteraction(context.Background(), bw, true)
84	require.False(t, toaster.suspended, "legend must be restored when the interaction ends")
85	require.False(t, bw.keyboardHandler.PageKeyCaptureActive())
86}
87
88// The page moves between hints, caret, visual and visual line without ending
89// the interaction: the toast follows each state while key capture and the
90// suspended legend stay in place.
91func TestVimPageModeChangesRepaintToastWithoutReleasingCapture(t *testing.T) {
92	app, bw, shown := newVimPageTestWindow(t)
93	toaster := bw.modeToaster
94	wv := portmocks.NewMockWebView(t)
95	app.captureVimPageKeys(context.Background(), bw, wv, visualOutcome)
96	require.Equal(t, "VIM MODE · HINTS", (*shown)[len(*shown)-1])
97
98	for mode, want := range map[dto.VimPageMode]string{
99		dto.VimPageModeCaret:      "VIM MODE · CARET",
100		dto.VimPageModeVisual:     "VIM MODE · VISUAL",
101		dto.VimPageModeVisualLine: "VIM MODE · VISUAL LINE",
102		dto.VimPageModeHints:      "VIM MODE · HINTS",
103	} {
104		app.showVimPageMode(context.Background(), bw, mode)
105		require.Equal(t, want, (*shown)[len(*shown)-1])
106		require.True(t, bw.keyboardHandler.PageKeyCaptureActive(), "mode %s must not release key capture", mode)
107		require.NotNil(t, bw.vimPageInteractionWebView)
108		require.True(t, toaster.suspended, "legend stays hidden while the page owns the keys")
109	}
110}
111
112func TestVimPageModeChangeIgnoredWithoutInteraction(t *testing.T) {
113	app, bw, shown := newVimPageTestWindow(t)
114	before := len(*shown)
115
116	app.showVimPageMode(context.Background(), bw, dto.VimPageModeCaret)
117
118	require.Len(t, *shown, before, "a late mode report must not repaint the indicator once the interaction ended")
119}
120
121// exitModeWithin runs fn and fails the test if it does not return in time,
122// so a lock re-entry deadlock shows up as a failure instead of a hung run.
123func exitModeWithin(t *testing.T, fn func()) {
124	t.Helper()
125	done := make(chan struct{})
126	go func() {
127		defer close(done)
128		fn()
129	}()
130	select {
131	case <-done:
132	case <-time.After(3 * time.Second):
133		t.Fatal("ExitMode did not return: mode-change callback re-entered the modal lock")
134	}
135}
136
137// ExitMode runs the mode-change callback under the modal lock. Releasing the
138// page capture from that callback must not read the mode back through the lock.
139func TestExitModeWithPageCaptureDoesNotDeadlock(t *testing.T) {
140	app, bw, _ := newVimPageTestWindow(t)
141	bw.keyboardHandler.SetOnModeChange(func(from, to input.Mode) {
142		app.handleModeChange(context.Background(), bw, from, to)
143	})
144	wv := portmocks.NewMockWebView(t)
145	wv.EXPECT().ID().Return(1).Maybe()
146	app.captureVimPageKeys(context.Background(), bw, wv, visualOutcome)
147
148	exitModeWithin(t, bw.keyboardHandler.ExitMode)
149
150	require.False(t, bw.keyboardHandler.PageKeyCaptureActive())
151	require.Nil(t, bw.vimPageInteractionWebView)
152	require.Equal(t, input.ModeNormal, bw.keyboardHandler.Mode())
153}
154
155func TestVimPageModeLabel(t *testing.T) {
156	for mode, want := range map[dto.VimPageMode]string{
157		dto.VimPageModeHints:      "HINTS",
158		dto.VimPageModeCaret:      "CARET",
159		dto.VimPageModeVisual:     "VISUAL",
160		dto.VimPageModeVisualLine: "VISUAL LINE",
161	} {
162		got, ok := vimPageModeLabel(mode)
163		require.True(t, ok, mode)
164		require.Equal(t, want, got)
165	}
166	_, ok := vimPageModeLabel("bogus")
167	require.False(t, ok)
168}
169
170// One mapping drives every label: the sub-mode, plus the target of hints that
171// do more than follow. Later page-reported modes drop the hint target.
172func TestVimPageLabel(t *testing.T) {
173	tests := []struct {
174		kind dto.VimPageInteractionKind
175		mode dto.VimPageMode
176		want string
177	}{
178		{dto.VimPageVisual, dto.VimPageModeHints, "HINTS"},
179		{dto.VimPageHintFollow, dto.VimPageModeHints, "HINTS"},
180		{dto.VimPageHintFollowNew, dto.VimPageModeHints, "HINTS · NEW PANE"},
181		{dto.VimPageHintYankURL, dto.VimPageModeHints, "HINTS · YANK URL"},
182		{dto.VimPageVisual, dto.VimPageModeCaret, "CARET"},
183		{dto.VimPageVisual, dto.VimPageModeVisual, "VISUAL"},
184		{dto.VimPageVisual, dto.VimPageModeVisualLine, "VISUAL LINE"},
185	}
186	for _, tt := range tests {
187		got, ok := vimPageLabel(tt.kind, tt.mode)
188		require.True(t, ok)
189		require.Equal(t, tt.want, got, "kind %d mode %s", tt.kind, tt.mode)
190	}
191	_, ok := vimPageLabel(dto.VimPageVisual, "bogus")
192	require.False(t, ok)
193}
194
195func TestVimPageInteractionLabelsFollowTheOutcome(t *testing.T) {
196	app, bw, shown := newVimPageTestWindow(t)
197	wv := portmocks.NewMockWebView(t)
198
199	app.captureVimPageKeys(context.Background(), bw, wv, hintsOutcome(dto.VimPageHintFollowNew))
200	require.Equal(t, "VIM MODE · HINTS · NEW PANE", (*shown)[len(*shown)-1])
201	app.releaseVimPageKeys(context.Background(), bw, "test", true)
202
203	app.captureVimPageKeys(context.Background(), bw, wv, hintsOutcome(dto.VimPageHintYankURL))
204	require.Equal(t, "VIM MODE · HINTS · YANK URL", (*shown)[len(*shown)-1])
205}

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