v0.33.2 57f21d6

mode_toaster_test.go RawHistoryFolder

1package ui
2
3import (
4	"context"
5	"sync"
6	"testing"
7	"time"
8
9	"github.com/bnema/dumber/internal/domain/entity"
10	"github.com/bnema/dumber/internal/ui/input"
11	"github.com/stretchr/testify/require"
12)
13
14var testVimActions = map[string]entity.ActionBinding{
15	"vim-scroll-down": {Keys: []string{"j"}, Desc: "Scroll down"},
16	"heading-next":    {Keys: []string{"]]"}},
17	"visual":          {Keys: []string{"v"}},
18	"cancel":          {Keys: []string{"esc"}},
19}
20
21func legendOn(mode string) entity.WorkspaceStylingConfig {
22	return entity.WorkspaceStylingConfig{ModeIndicatorToasterEnabled: true, ModeLegend: mode, ModeLegendAnimations: true}
23}
24
25func newTestToaster(onToggle func(bool)) (*modeToaster, *fakeModeToasterView) {
26	view := newFakeModeToasterView()
27	return newModeToasterWithView(view, onToggle), view
28}
29
30func TestModeToasterHeader(t *testing.T) {
31	require.Equal(t, "VIM MODE", modeToasterHeader(input.ModeVim, ""))
32	require.Equal(t, "VIM MODE · 2g", modeToasterHeader(input.ModeVim, "2g"))
33	require.Equal(t, "VIM MODE · HINTS · NEW PANE", modeToasterHeader(input.ModeVim, "HINTS · NEW PANE"))
34	require.Equal(t, "PANE MODE", modeToasterHeader(input.ModePane, ""))
35}
36
37func TestModeToasterShowsPillAndLegendOnEnter(t *testing.T) {
38	toaster, view := newTestToaster(nil)
39
40	toaster.apply(input.ModeVim, legendOn("always"), "command", testVimActions)
41
42	require.True(t, view.pillShown)
43	require.Equal(t, "VIM MODE", view.header)
44	require.Equal(t, input.ModeVim, view.mode)
45	require.True(t, view.bodyShown)
46	require.Equal(t, chevronExpanded, view.chevron)
47	require.True(t, view.interactive)
48	require.Equal(t, "command", view.style)
49	var names []string
50	for _, group := range view.groups {
51		names = append(names, group.Name)
52	}
53	require.Equal(t, []string{"SCROLL", "JUMP", "SELECT", "EXIT"}, names)
54}
55
56func TestModeToasterDelayWithholdsLegendNotPill(t *testing.T) {
57	toaster, view := newTestToaster(nil)
58	cfg := legendOn("delay")
59	cfg.ModeLegendDelayMs = 20
60
61	toaster.apply(input.ModeVim, cfg, "", testVimActions)
62	require.True(t, view.pillShown, "the pill appears at once")
63	require.False(t, view.bodyShown, "the legend waits for the delay")
64
65	waitFor(t, func() bool { return view.bodyShown })
66}
67
68func TestModeToasterDelayZeroShowsLegendAtOnce(t *testing.T) {
69	toaster, view := newTestToaster(nil)
70	cfg := legendOn("delay")
71	cfg.ModeLegendDelayMs = 0
72
73	toaster.apply(input.ModeVim, cfg, "", testVimActions)
74
75	require.True(t, view.bodyShown)
76}
77
78func TestModeToasterModeExitBeforeDelayNeverShowsLegend(t *testing.T) {
79	toaster, view := newTestToaster(nil)
80	cfg := legendOn("delay")
81	cfg.ModeLegendDelayMs = 30
82	toaster.apply(input.ModeVim, cfg, "", testVimActions)
83	toaster.apply(input.ModeNormal, cfg, "", nil)
84
85	time.Sleep(80 * time.Millisecond)
86	pumpMainContext()
87
88	require.False(t, view.bodyShown)
89	require.False(t, view.pillShown)
90}
91
92func TestModeToasterLegendOffIsPillOnlyWithoutChevron(t *testing.T) {
93	toaster, view := newTestToaster(nil)
94
95	toaster.apply(input.ModeVim, legendOn("off"), "", testVimActions)
96
97	require.True(t, view.pillShown)
98	require.False(t, view.bodyShown)
99	require.Equal(t, chevronNone, view.chevron)
100	require.False(t, view.interactive, "nothing to toggle: the pill ignores the pointer")
101	toaster.pillClicked()
102	require.False(t, toaster.collapsed, "a click on a legend-less pill does nothing")
103}
104
105func TestModeToasterNothingWhenIndicatorAndLegendOff(t *testing.T) {
106	toaster, view := newTestToaster(nil)
107	cfg := entity.WorkspaceStylingConfig{ModeLegend: "off"}
108
109	toaster.apply(input.ModeVim, cfg, "", testVimActions)
110
111	require.False(t, view.pillShown)
112	require.False(t, view.bodyShown)
113}
114
115func TestModeToasterPillClickTogglesThroughTheApp(t *testing.T) {
116	var asked []bool
117	toaster, view := newTestToaster(func(collapsed bool) { asked = append(asked, collapsed) })
118	toaster.apply(input.ModeVim, legendOn("always"), "", testVimActions)
119
120	toaster.pillClicked()
121
122	require.Equal(t, []bool{true}, asked)
123	require.True(t, view.bodyShown, "the toaster waits for the app to broadcast the new state")
124}
125
126func TestModeToasterCollapseHidesBodyKeepsPill(t *testing.T) {
127	toaster, view := newTestToaster(nil)
128	toaster.apply(input.ModeVim, legendOn("always"), "", testVimActions)
129
130	toaster.setCollapsed(true)
131
132	require.False(t, view.bodyShown)
133	require.True(t, view.pillShown, "collapsed leaves the pill, clickable to re-expand")
134	require.True(t, view.interactive)
135	require.Equal(t, chevronCollapsed, view.chevron)
136
137	toaster.setCollapsed(false)
138	require.True(t, view.bodyShown)
139	require.Equal(t, chevronExpanded, view.chevron)
140}
141
142func TestModeToasterPillClickWithoutAppTogglesLocally(t *testing.T) {
143	toaster, view := newTestToaster(nil)
144	toaster.apply(input.ModeVim, legendOn("always"), "", testVimActions)
145
146	toaster.pillClicked()
147	require.False(t, view.bodyShown)
148	toaster.pillClicked()
149	require.True(t, view.bodyShown)
150}
151
152func TestModeToasterStartsCollapsedFromConfiguration(t *testing.T) {
153	toaster, view := newTestToaster(nil)
154	cfg := legendOn("always")
155	cfg.ModeLegendCollapsed = true
156
157	toaster.apply(input.ModePane, cfg, "", map[string]entity.ActionBinding{"split-right": {Keys: []string{"r"}}})
158
159	require.True(t, view.pillShown)
160	require.False(t, view.bodyShown)
161	require.Equal(t, chevronCollapsed, view.chevron)
162}
163
164func TestModeToasterCollapsedStateSurvivesModeChanges(t *testing.T) {
165	toaster, view := newTestToaster(nil)
166	cfg := legendOn("always")
167	cfg.ModeLegendCollapsed = true
168	pane := map[string]entity.ActionBinding{"split-right": {Keys: []string{"r"}}}
169
170	toaster.apply(input.ModeVim, cfg, "", testVimActions)
171	toaster.apply(input.ModePane, cfg, "", pane)
172
173	require.False(t, view.bodyShown, "the collapsed state is shared by all modes")
174	require.Equal(t, input.ModePane, view.mode)
175}
176
177func TestModeToasterExpandingSkipsRemainingDelay(t *testing.T) {
178	toaster, view := newTestToaster(nil)
179	cfg := legendOn("delay")
180	cfg.ModeLegendDelayMs = 10_000
181	cfg.ModeLegendCollapsed = true
182	toaster.apply(input.ModeVim, cfg, "", testVimActions)
183
184	require.False(t, view.bodyShown)
185
186	toaster.setCollapsed(false)
187	require.True(t, view.bodyShown, "expanding is a request to see the legend now, not after the delay")
188	require.Zero(t, toaster.showTimer)
189}
190
191func TestModeToasterConfigReloadDoesNotRestartLegend(t *testing.T) {
192	toaster, view := newTestToaster(nil)
193	cfg := legendOn("always")
194	toaster.apply(input.ModeVim, cfg, "", testVimActions)
195	generation := toaster.generation
196	view.groups = nil
197
198	// A reload that only flips the collapsed preference (the app's own save).
199	cfg.ModeLegendCollapsed = true
200	toaster.apply(input.ModeVim, cfg, "", testVimActions)
201
202	require.Equal(t, generation, toaster.generation, "not a mode change")
203	require.Nil(t, view.groups, "the legend is not rebuilt")
204	require.False(t, view.bodyShown)
205}
206
207func TestModeToasterConfigReloadRebuildsWhenBindingsChange(t *testing.T) {
208	toaster, view := newTestToaster(nil)
209	toaster.apply(input.ModeVim, legendOn("always"), "", testVimActions)
210
211	changed := map[string]entity.ActionBinding{"visual": {Keys: []string{"v"}}}
212	toaster.apply(input.ModeVim, legendOn("always"), "", changed)
213
214	require.Len(t, view.groups, 1)
215	require.Equal(t, "SELECT", view.groups[0].Name)
216}
217
218func TestModeToasterIndicatorPreferenceReloadKeepsLegend(t *testing.T) {
219	toaster, view := newTestToaster(nil)
220	toaster.apply(input.ModeVim, legendOn("always"), "", testVimActions)
221
222	noIndicator := legendOn("always")
223	noIndicator.ModeIndicatorToasterEnabled = false
224	toaster.apply(input.ModeVim, noIndicator, "", testVimActions)
225	require.True(t, view.pillShown, "legend still on: the pill remains as its header")
226	require.True(t, view.bodyShown)
227
228	toaster.apply(input.ModeVim, legendOn("off"), "", testVimActions)
229	require.True(t, view.pillShown, "indicator back on, legend off: plain pill")
230	require.False(t, view.bodyShown)
231}
232
233func TestModeToasterPendingSequenceShowsInHeaderAndDimsRows(t *testing.T) {
234	toaster, view := newTestToaster(nil)
235	toaster.apply(input.ModeVim, legendOn("always"), "", testVimActions)
236
237	toaster.setPending("]")
238
239	require.Equal(t, "VIM MODE · ]", view.header)
240	require.False(t, view.dimmed["heading-next"])
241	require.True(t, view.dimmed["vim-scroll-down"])
242	require.True(t, view.dimmed["visual"])
243
244	toaster.setPending("")
245	require.Equal(t, "VIM MODE", view.header)
246	require.False(t, view.dimmed["vim-scroll-down"])
247}
248
249func TestModeToasterPendingOnlyAppliesToVimMode(t *testing.T) {
250	toaster, view := newTestToaster(nil)
251	toaster.apply(input.ModePane, legendOn("always"), "", map[string]entity.ActionBinding{"split-right": {Keys: []string{"r"}}})
252
253	toaster.setPending("x")
254
255	require.Equal(t, "PANE MODE", view.header)
256	require.False(t, view.dimmed["split-right"])
257}
258
259func TestModeToasterSubModeSuspendsLegendButKeepsHeader(t *testing.T) {
260	toaster, view := newTestToaster(nil)
261	toaster.apply(input.ModeVim, legendOn("always"), "", testVimActions)
262
263	toaster.setDetail("HINTS")
264	toaster.setSuspended(true)
265	require.Equal(t, "VIM MODE · HINTS", view.header)
266	require.False(t, view.bodyShown, "the page owns the keys: the legend would list keys that do not apply")
267	require.True(t, view.pillShown)
268
269	toaster.setDetail("")
270	toaster.setSuspended(false)
271	require.Equal(t, "VIM MODE", view.header)
272	require.True(t, view.bodyShown)
273}
274
275func TestModeToasterModeChangeResetsDetailAndSuspension(t *testing.T) {
276	toaster, view := newTestToaster(nil)
277	toaster.apply(input.ModeVim, legendOn("always"), "", testVimActions)
278	toaster.setDetail("VISUAL")
279	toaster.setSuspended(true)
280
281	toaster.apply(input.ModeNormal, legendOn("always"), "", nil)
282	toaster.apply(input.ModeVim, legendOn("always"), "", testVimActions)
283
284	require.Equal(t, "VIM MODE", view.header)
285	require.True(t, view.bodyShown)
286}
287
288func TestModeToasterSameModeRebuildKeepsInteractionState(t *testing.T) {
289	toaster, view := newTestToaster(nil)
290	toaster.apply(input.ModeVim, legendOn("always"), "", testVimActions)
291	toaster.setDetail("HINTS")
292	toaster.setSuspended(true)
293	require.False(t, view.bodyShown)
294
295	// A hot reload that changes the bindings rebuilds the legend mid-capture.
296	changed := map[string]entity.ActionBinding{"visual": {Keys: []string{"v"}}, "cancel": {Keys: []string{"esc"}}}
297	toaster.apply(input.ModeVim, legendOn("always"), "", changed)
298
299	require.Equal(t, "VIM MODE · HINTS", view.header, "the sub-mode label survives the rebuild")
300	require.False(t, view.bodyShown, "the legend stays suspended while the page owns the keys")
301	require.True(t, toaster.suspended)
302
303	toaster.setDetail("")
304	toaster.setSuspended(false)
305	require.True(t, view.bodyShown)
306	require.Equal(t, "VIM MODE", view.header)
307}
308
309func TestModeToasterSameModeRebuildReappliesPendingDimming(t *testing.T) {
310	toaster, view := newTestToaster(nil)
311	toaster.apply(input.ModeVim, legendOn("always"), "", testVimActions)
312	toaster.setPending("]")
313
314	changed := map[string]entity.ActionBinding{
315		"heading-next": {Keys: []string{"]]"}},
316		"visual":       {Keys: []string{"v"}},
317	}
318	toaster.apply(input.ModeVim, legendOn("always"), "", changed)
319	// The rebuilt legend starts undimmed, as a real view's new rows would.
320	require.Equal(t, "]", toaster.pending)
321	require.Equal(t, "VIM MODE · ]", view.header)
322	require.False(t, view.dimmed["heading-next"])
323	require.True(t, view.dimmed["visual"], "rows the pending sequence rules out are dimmed again")
324}
325
326func TestModeToasterFlashLightsKeycapsAndClears(t *testing.T) {
327	toaster, view := newTestToaster(nil)
328	toaster.apply(input.ModeVim, legendOn("always"), "", testVimActions)
329
330	toaster.flash(input.Action("vim-scroll-down"))
331	require.True(t, view.flashed["vim-scroll-down"])
332
333	// Underscore action names map onto dashed legend rows.
334	toaster.flash(input.Action("heading_next"))
335	require.True(t, view.flashed["heading-next"])
336
337	toaster.flash(input.Action("unknown-action"))
338	require.True(t, view.flashed["heading-next"], "an action without keycaps leaves the flash alone")
339
340	waitFor(t, func() bool { return len(view.flashed) == 0 })
341}
342
343func TestModeToasterFlashNeedsVisibleLegendAndAnimations(t *testing.T) {
344	toaster, view := newTestToaster(nil)
345	cfg := legendOn("always")
346	cfg.ModeLegendAnimations = false
347	toaster.apply(input.ModeVim, cfg, "", testVimActions)
348	toaster.flash(input.Action("vim-scroll-down"))
349	require.Empty(t, view.flashed)
350
351	toaster.apply(input.ModeVim, legendOn("always"), "", testVimActions)
352	toaster.setCollapsed(true)
353	toaster.flash(input.Action("vim-scroll-down"))
354	require.Empty(t, view.flashed, "collapsed legend: nothing to flash")
355}
356
357func TestModeToasterLingersWhenPointerIsOverLegend(t *testing.T) {
358	toaster, view := newTestToaster(nil)
359	cfg := legendOn("always")
360	cfg.ModeLegendLinger = true
361	toaster.apply(input.ModeVim, cfg, "", testVimActions)
362	view.rect = func() (int, int, int, int, bool) { return 10, 10, 400, 200, true }
363	toaster.pointerMoved(50, 50)
364
365	toaster.apply(input.ModeNormal, cfg, "", nil)
366
367	require.True(t, toaster.lingering)
368	require.True(t, view.lingering)
369	require.True(t, view.bodyShown, "the legend stays on screen, read-only")
370	require.False(t, view.pillShown, "the mode ended: the header goes")
371	require.True(t, toaster.hasLegend() || toaster.lingering)
372
373	toaster.keyPressed()
374	require.False(t, toaster.lingering)
375	require.False(t, view.bodyShown)
376}
377
378func TestModeToasterDoesNotLingerWhenPointerElsewhereOrPressInFlight(t *testing.T) {
379	cfg := legendOn("always")
380	cfg.ModeLegendLinger = true
381
382	toaster, view := newTestToaster(nil)
383	toaster.apply(input.ModeVim, cfg, "", testVimActions)
384	view.rect = func() (int, int, int, int, bool) { return 10, 10, 400, 200, true }
385	toaster.pointerMoved(900, 900)
386	toaster.apply(input.ModeNormal, cfg, "", nil)
387	require.False(t, toaster.lingering)
388
389	toaster, view = newTestToaster(nil)
390	toaster.apply(input.ModeVim, cfg, "", testVimActions)
391	view.rect = func() (int, int, int, int, bool) { return 10, 10, 400, 200, true }
392	toaster.pointerMoved(50, 50)
393	toaster.pressStarted() // the exit was caused by a click
394	toaster.apply(input.ModeNormal, cfg, "", nil)
395	require.False(t, toaster.lingering)
396}
397
398func TestModeToasterLingerEndsOnClickAndTabSwitch(t *testing.T) {
399	cfg := legendOn("always")
400	cfg.ModeLegendLinger = true
401	toaster, view := newTestToaster(nil)
402	toaster.apply(input.ModeVim, cfg, "", testVimActions)
403	view.rect = func() (int, int, int, int, bool) { return 0, 0, 500, 500, true }
404	toaster.pointerMoved(5, 5)
405	toaster.apply(input.ModeNormal, cfg, "", nil)
406	require.True(t, toaster.lingering)
407
408	toaster.pressStarted()
409	require.False(t, toaster.lingering)
410	pumpMainContext() // the press is over; a later exit may linger again
411
412	toaster.apply(input.ModeVim, cfg, "", testVimActions)
413	toaster.pointerMoved(5, 5)
414	toaster.apply(input.ModeNormal, cfg, "", nil)
415	require.True(t, toaster.lingering)
416	toaster.dismissLinger()
417	require.False(t, toaster.lingering)
418}
419
420func TestModeToasterReleaseDropsLegendKeepsPill(t *testing.T) {
421	toaster, view := newTestToaster(nil)
422	cfg := legendOn("delay")
423	cfg.ModeLegendDelayMs = 40
424	toaster.apply(input.ModeVim, cfg, "", testVimActions)
425
426	toaster.release()
427	time.Sleep(90 * time.Millisecond)
428	pumpMainContext()
429
430	require.True(t, view.pillShown, "tab teardown keeps the mode header")
431	require.False(t, view.bodyShown, "the pending show for the released workspace never fires")
432}
433
434func TestModeToasterNilAndDestroyedAreSafe(t *testing.T) {
435	var nilToaster *modeToaster
436	nilToaster.destroy()
437	nilToaster.release()
438	nilToaster.dismissLinger()
439	nilToaster.setSuspended(true)
440	nilToaster.setCollapsed(true)
441	nilToaster.setPending("x")
442	nilToaster.setDetail("x")
443	nilToaster.flash(input.Action("x"))
444	nilToaster.apply(input.ModeVim, legendOn("always"), "", nil)
445
446	toaster, view := newTestToaster(nil)
447	toaster.destroy()
448	require.True(t, view.destroyed)
449	toaster.apply(input.ModeVim, legendOn("always"), "", testVimActions)
450	toaster.setPending("x")
451	toaster.pillClicked()
452}
453
454func TestModeLegendPreferenceClickWinsUntilSettled(t *testing.T) {
455	var p modeLegendPreference
456	require.False(t, p.current(false))
457	require.True(t, p.current(true), "no click yet: the configuration decides")
458
459	require.True(t, p.click(true), "first click starts the writer")
460	require.True(t, p.current(false), "the click wins while the config still says otherwise")
461
462	p.configReloaded(false) // a reload from before the save landed
463	require.True(t, p.current(false), "a stale reload does not undo the click")
464
465	next, ok := p.next()
466	require.True(t, ok)
467	require.True(t, next)
468	_, ok = p.next()
469	require.False(t, ok, "writer drained")
470
471	p.configReloaded(true) // the save's own reload
472	require.False(t, p.current(false), "settled: the configuration is the truth again")
473	require.True(t, p.current(true))
474}
475
476func TestModeLegendPreferenceReloadsDuringSavesDoNotUndoLastClick(t *testing.T) {
477	var p modeLegendPreference
478	require.True(t, p.click(true))
479	first, ok := p.next() // the writer takes the first value
480	require.True(t, ok)
481	require.True(t, first)
482	require.False(t, p.click(false), "the writer is still active")
483
484	p.configReloaded(false) // matches the latest click, but the save is still pending
485	p.configReloaded(true)  // the first save's own reload, arriving late
486	require.False(t, p.current(true), "the latest click survives intermediate reloads")
487
488	second, ok := p.next()
489	require.True(t, ok)
490	require.False(t, second)
491	require.False(t, p.current(true), "still the latest click until the writer drains")
492	_, ok = p.next()
493	require.False(t, ok)
494	require.False(t, p.current(true), "drained: reloads have not been received yet")
495
496	p.configReloaded(false) // first reload after the drain
497	require.False(t, p.current(false))
498	require.True(t, p.current(true), "the configuration decides again")
499}
500
501func TestModeLegendPreferenceReloadAfterDrain(t *testing.T) {
502	tests := []struct {
503		name       string
504		reloaded   bool
505		configured bool
506	}{
507		{"own save reloads", true, true},
508		{"external edit", false, false},
509		{"failed save leaves the file unchanged", false, false},
510	}
511	for _, tt := range tests {
512		t.Run(tt.name, func(t *testing.T) {
513			var p modeLegendPreference
514			require.True(t, p.click(true))
515			_, _ = p.next()
516			_, _ = p.next()
517			p.configReloaded(tt.reloaded)
518			require.Equal(t, tt.configured, p.current(tt.configured))
519			require.Equal(t, !tt.configured, p.current(!tt.configured))
520		})
521	}
522}
523
524func TestModeLegendPreferenceCoalescesClicksAndKeepsLast(t *testing.T) {
525	var p modeLegendPreference
526	require.True(t, p.click(true))
527	require.False(t, p.click(false), "a running writer picks the new value up")
528	require.False(t, p.click(true))
529
530	next, ok := p.next()
531	require.True(t, ok)
532	require.True(t, next, "the file ends on the last click")
533	_, ok = p.next()
534	require.False(t, ok)
535
536	require.True(t, p.click(false), "after draining, a new click starts a new writer")
537	_, _ = p.next()
538	_, _ = p.next()
539}
540
541func TestModeLegendPreferenceExternalEditWinsWhenNoSaveInFlight(t *testing.T) {
542	var p modeLegendPreference
543	require.True(t, p.click(true))
544	_, _ = p.next()
545	_, _ = p.next() // save finished (and possibly failed)
546
547	p.configReloaded(false) // the file still says false
548	require.False(t, p.current(false))
549}
550
551func TestSetModeLegendCollapsedUpdatesEveryWindowAndSavesAsync(t *testing.T) {
552	saved := make(chan bool, 4)
553	var mu sync.Mutex
554	app := &App{}
555	app.deps = &Dependencies{}
556	app.deps.HandlerDeps.SaveModeLegendCollapsed = func(_ context.Context, collapsed bool) error {
557		mu.Lock()
558		defer mu.Unlock()
559		saved <- collapsed
560		return nil
561	}
562	bw1, view1 := newFakeModeToasterWindow("win-1")
563	bw2, view2 := newFakeModeToasterWindow("win-2")
564	app.browserWindows = map[string]*browserWindow{bw1.id: bw1, bw2.id: bw2}
565	bw1.modeToaster.apply(input.ModeVim, legendOn("always"), "", testVimActions)
566	bw2.modeToaster.apply(input.ModePane, legendOn("always"), "", map[string]entity.ActionBinding{"split-right": {Keys: []string{"r"}}})
567
568	app.setModeLegendCollapsed(context.Background(), true)
569
570	require.False(t, view1.bodyShown)
571	require.False(t, view2.bodyShown, "the toggle applies immediately in every window")
572	require.True(t, view1.pillShown)
573	require.True(t, view2.pillShown)
574	select {
575	case got := <-saved:
576		require.True(t, got)
577	case <-time.After(2 * time.Second):
578		t.Fatal("collapsed preference was not saved")
579	}
580	require.True(t, app.modeLegendCollapsed())
581}
582
583func TestSetModeLegendCollapsedLogsSaveFailureAndKeepsState(t *testing.T) {
584	done := make(chan struct{}, 1)
585	app := &App{deps: &Dependencies{}}
586	app.deps.HandlerDeps.SaveModeLegendCollapsed = func(context.Context, bool) error {
587		defer func() { done <- struct{}{} }()
588		return context.DeadlineExceeded
589	}
590	bw, view := newFakeModeToasterWindow("win-1")
591	app.browserWindows = map[string]*browserWindow{bw.id: bw}
592	bw.modeToaster.apply(input.ModeVim, legendOn("always"), "", testVimActions)
593
594	app.setModeLegendCollapsed(context.Background(), true)
595	<-done
596
597	require.False(t, view.bodyShown, "a failed save does not revert the visible state")
598}
599
600func TestUpdateModeFrameUsesSharedCollapsedState(t *testing.T) {
601	snapshot := entity.RuntimeConfigSnapshot{UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{
602		Styling: legendOn("always"),
603		VimMode: entity.VimModeConfig{Actions: testVimActions},
604	}}}
605	app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(snapshot)}
606	require.True(t, app.modeLegend.click(true))
607	bw, view := newFakeModeToasterWindow("win-1")
608
609	app.updateModeFrame(bw, input.ModeVim)
610
611	require.True(t, view.pillShown)
612	require.False(t, view.bodyShown, "a window entering a mode honors the shared collapsed state")
613	_, _ = app.modeLegend.next()
614	_, _ = app.modeLegend.next()
615}
616
617func waitFor(t *testing.T, cond func() bool) {
618	t.Helper()
619	deadline := time.Now().Add(2 * time.Second)
620	for time.Now().Before(deadline) {
621		pumpMainContext()
622		if cond() {
623			return
624		}
625		time.Sleep(5 * time.Millisecond)
626	}
627	t.Fatal("condition not reached in time")
628}

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