notices_overlay_test.go RawHistoryFolder

1package daemon
2
3import (
4	"encoding/base64"
5	"fmt"
6	"strings"
7	"testing"
8	"time"
9
10	"github.com/stretchr/testify/require"
11
12	renderer "github.com/bnema/vev-vt"
13	"github.com/bnema/vev/internal/domain"
14	"github.com/bnema/vev/internal/usecase/notices"
15	themeui "github.com/bnema/vev/internal/usecase/theme"
16)
17
18func TestCaptureNoticesOverlayUsesTerminalBackgroundOutsideSelection(t *testing.T) {
19	terminal := renderer.DefaultStyle()
20	inactive := renderer.Style{HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 10, G: 20, B: 30}}
21	selection := renderer.Style{HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 40, G: 50, B: 60}}
22	styles := themeui.Styles{
23		PickerBase: terminal, SurfaceInactive: inactive, PickerSelection: selection,
24		PickerSelectionName: selection, PickerSelectionMuted: selection,
25		PickerDescription: terminal,
26	}
27	model := notices.New([]domain.Notification{
28		{Code: domain.NoticePaneSpawn, Message: "selected", Time: time.Unix(2, 0)},
29		{Code: domain.NoticeTabSpawn, Message: "inactive", Time: time.Unix(1, 0)},
30	}, time.Unix(3, 0))
31	state := capturedRenderState{styles: styles, layout: capturedTabLayout{area: domain.Rect{Width: 100, Height: 38}}}
32	snap := &overlayRenderSnapshot{noticesOverlayActive: true, noticesOverlayModel: model}
33
34	captureOverlayLayers(&state, snap, domain.PaletteConfig{})
35
36	inner := state.overlays.noticesOverlay.inner
37	require.True(t, inner.At(inner.Width-1, 1).Style.Equal(terminal), "inactive row keeps the terminal background")
38	require.True(t, inner.At(inner.Width-1, 2).Style.Equal(terminal), "unused interior keeps the terminal background")
39	require.True(t, inner.At(inner.Width-1, 0).Style.Equal(selection), "selected row keeps the accent background")
40}
41
42func TestNoticesOpenViaPaletteCommandShowsModalWithHistory(t *testing.T) {
43	p, release := newBlockingPTY(t)
44	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
45	defer release()
46	d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Severity: domain.NoticeError, Message: "couldn't open pane", Time: time.Unix(1, 0)})
47
48	d.enterPalette(sess, ac)
49	awaitFrame(t, sends, "Output")
50	d.handlePaletteInput(ac, []byte("NTC\r"))
51	out := awaitFrame(t, sends, "Output")
52
53	require.False(t, ac.overlays.paletteActive())
54	require.NotNil(t, ac.overlays.noticesOverlay)
55	msg := unmarshalTestOutput(t, out.Payload)
56	require.Contains(t, string(msg.Data), "Notifications")
57	require.Contains(t, string(msg.Data), "pane-spawn")
58	require.Contains(t, string(msg.Data), "couldn't open pane")
59}
60
61func TestNoticesJKNavigatesSelection(t *testing.T) {
62	p, release := newBlockingPTY(t)
63	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
64	defer release()
65	d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "first", Time: time.Unix(1, 0)})
66	d.notices.record(domain.Notification{Code: domain.NoticeTabSpawn, Message: "second", Time: time.Unix(2, 0)})
67	d.notices.record(domain.Notification{Code: domain.NoticeInternal, Message: "third", Time: time.Unix(3, 0)})
68
69	d.enterNotices(sess, ac)
70	awaitFrame(t, sends, "Output")
71	selected, ok := ac.overlays.noticesOverlay.Selected()
72	require.True(t, ok)
73	require.Equal(t, "third", selected.Message)
74
75	d.handleInput(sess, ac, []byte("j"))
76	awaitFrame(t, sends, "Output")
77	selected, ok = ac.overlays.noticesOverlay.Selected()
78	require.True(t, ok)
79	require.Equal(t, "second", selected.Message)
80
81	d.handleInput(sess, ac, []byte("j"))
82	awaitFrame(t, sends, "Output")
83	selected, ok = ac.overlays.noticesOverlay.Selected()
84	require.True(t, ok)
85	require.Equal(t, "first", selected.Message)
86
87	// One more 'j' at the bottom must clamp rather than wrap.
88	d.handleInput(sess, ac, []byte("j"))
89	awaitFrame(t, sends, "Output")
90	selected, ok = ac.overlays.noticesOverlay.Selected()
91	require.True(t, ok)
92	require.Equal(t, "first", selected.Message)
93
94	d.handleInput(sess, ac, []byte("k"))
95	awaitFrame(t, sends, "Output")
96	selected, ok = ac.overlays.noticesOverlay.Selected()
97	require.True(t, ok)
98	require.Equal(t, "second", selected.Message)
99}
100
101func TestNoticesQAndCtrlCCloseImmediatelyAndClearState(t *testing.T) {
102	for _, key := range []byte{'q', 0x03} {
103		t.Run(fmt.Sprintf("key=%q", key), func(t *testing.T) {
104			p, release := newBlockingPTY(t)
105			defer release()
106			d, sess, ac, sends := newManualSessionWithPTYs(t, p)
107			d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "m", Time: time.Unix(1, 0)})
108
109			d.enterNotices(sess, ac)
110			awaitFrame(t, sends, "Output")
111			require.True(t, ac.overlays.noticesActive())
112
113			d.handleInput(sess, ac, []byte{key})
114			awaitFrame(t, sends, "Output")
115
116			require.False(t, ac.overlays.noticesActive())
117			require.Nil(t, ac.overlays.noticesOverlay)
118		})
119	}
120}
121
122func TestNoticesLoneEscapeClosesAfterDelay(t *testing.T) {
123	p, release := newBlockingPTY(t)
124	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
125	defer release()
126	d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "m", Time: time.Unix(1, 0)})
127
128	d.enterNotices(sess, ac)
129	awaitFrame(t, sends, "Output")
130
131	clk := &signalClock{timers: make(chan *signalTimer, 1)}
132	d.clock = clk
133	d.handleInput(sess, ac, []byte("\x1b"))
134	timer := <-clk.timers
135	require.True(t, ac.overlays.noticesActive())
136
137	timer.ch <- time.Now()
138	require.Eventually(t, func() bool { return !ac.overlays.noticesActive() }, time.Second, 5*time.Millisecond)
139	awaitFrame(t, sends, "Output")
140}
141
142func TestNoticesSplitArrowNavigatesWithoutClosing(t *testing.T) {
143	cases := []struct {
144		name  string
145		input [][]byte
146		want  string
147	}{
148		{name: "escape then CSI down", input: [][]byte{[]byte("\x1b"), []byte("[B")}, want: "first"},
149		{name: "split CSI down", input: [][]byte{[]byte("\x1b["), []byte("B")}, want: "first"},
150		{name: "split SS3 down", input: [][]byte{[]byte("\x1bO"), []byte("B")}, want: "first"},
151		{name: "split CSI up", input: [][]byte{[]byte("j"), []byte("\x1b"), []byte("[A")}, want: "second"},
152	}
153	for _, tc := range cases {
154		t.Run(tc.name, func(t *testing.T) {
155			p, release := newBlockingPTY(t)
156			defer release()
157			d, sess, ac, sends := newManualSessionWithPTYs(t, p)
158			d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "first", Time: time.Unix(1, 0)})
159			d.notices.record(domain.Notification{Code: domain.NoticeTabSpawn, Message: "second", Time: time.Unix(2, 0)})
160
161			d.enterNotices(sess, ac)
162			awaitFrame(t, sends, "Output")
163			for _, input := range tc.input {
164				d.handleInput(sess, ac, input)
165			}
166
167			require.True(t, ac.overlays.noticesActive())
168			selected, ok := ac.overlays.noticesOverlay.Selected()
169			require.True(t, ok)
170			require.Equal(t, tc.want, selected.Message)
171		})
172	}
173}
174
175func TestHandleListInputConsumesSplitEscapePrefix(t *testing.T) {
176	for _, tt := range []struct {
177		name     string
178		prefix   []byte
179		tail     byte
180		wantUp   int
181		wantDown int
182	}{
183		{name: "CSI up", prefix: []byte("\x1b["), tail: 'A', wantUp: 1},
184		{name: "CSI down", prefix: []byte("\x1b["), tail: 'B', wantDown: 1},
185		{name: "SS3 up", prefix: []byte("\x1bO"), tail: 'A', wantUp: 1},
186		{name: "SS3 down", prefix: []byte("\x1bO"), tail: 'B', wantDown: 1},
187	} {
188		t.Run(tt.name, func(t *testing.T) {
189			var pending, custom []byte
190			var up, down int
191			state := listInputState{
192				pending:  &pending,
193				esc:      &pendingByteTimer{},
194				moveUp:   func() { up++ },
195				moveDown: func() { down++ },
196			}
197			action := func(b byte) listInputResult {
198				custom = append(custom, b)
199				return listInputResult{}
200			}
201
202			handleListInputLocked(nil, tt.prefix, state, action)
203			handleListInputLocked(nil, []byte{tt.tail}, state, action)
204
205			require.Equal(t, tt.wantUp, up)
206			require.Equal(t, tt.wantDown, down)
207			require.Empty(t, custom, "escape prefix bytes must not reach the custom action")
208			require.Empty(t, pending)
209		})
210	}
211}
212
213func TestHandleListInputConsumesUnsupportedCompleteEscapeSequences(t *testing.T) {
214	for _, tt := range []struct {
215		name  string
216		input [][]byte
217	}{
218		{name: "complete CSI right", input: [][]byte{[]byte("\x1b[C")}},
219		{name: "complete SS3 left", input: [][]byte{[]byte("\x1bOD")}},
220		{name: "parameterized CSI", input: [][]byte{[]byte("\x1b[1;5C")}},
221		{name: "split CSI right", input: [][]byte{[]byte("\x1b["), []byte("C")}},
222		{name: "split SS3 left", input: [][]byte{[]byte("\x1bO"), []byte("D")}},
223	} {
224		t.Run(tt.name, func(t *testing.T) {
225			var pending, custom []byte
226			state := listInputState{
227				pending:  &pending,
228				esc:      &pendingByteTimer{},
229				moveUp:   func() {},
230				moveDown: func() {},
231			}
232			action := func(b byte) listInputResult {
233				custom = append(custom, b)
234				return listInputResult{action: b}
235			}
236
237			var result listInputResult
238			for _, input := range tt.input {
239				result = handleListInputLocked(nil, input, state, action)
240			}
241
242			require.False(t, result.exit)
243			require.Zero(t, result.action)
244			require.Empty(t, custom, "complete escape bytes must not reach the custom action")
245			require.Empty(t, pending)
246		})
247	}
248}
249
250func TestHandleListInputStopsAfterExitInBatchedInput(t *testing.T) {
251	for _, tt := range []struct {
252		name       string
253		input      []byte
254		custom     func(byte) listInputResult
255		wantExit   bool
256		wantStop   bool
257		wantAction byte
258		wantCalls  []byte
259	}{
260		{
261			name:     "q ignores trailing navigation and actions",
262			input:    []byte("qjy"),
263			custom:   func(b byte) listInputResult { return listInputResult{action: b} },
264			wantExit: true,
265		},
266		{
267			name:     "ctrl-c ignores trailing navigation and actions",
268			input:    []byte{0x03, 'j', 'y'},
269			custom:   func(b byte) listInputResult { return listInputResult{action: b} },
270			wantExit: true,
271		},
272		{
273			name:       "invalid escape exit ignores trailing navigation and actions",
274			input:      []byte{'\x1b', 'x', 'j', 'y'},
275			custom:     func(b byte) listInputResult { return listInputResult{action: b} },
276			wantExit:   true,
277			wantCalls:  nil,
278			wantAction: 0,
279		},
280		{
281			name:  "custom exit retains action and ignores trailing bytes",
282			input: []byte("xjy"),
283			custom: func(b byte) listInputResult {
284				if b == 'x' {
285					return listInputResult{action: b, exit: true}
286				}
287				return listInputResult{action: b}
288			},
289			wantExit: true, wantAction: 'x', wantCalls: []byte("x"),
290		},
291		{
292			name:  "custom stop retains action and ignores trailing bytes",
293			input: []byte("xjy"),
294			custom: func(b byte) listInputResult {
295				if b == 'x' {
296					return listInputResult{action: b, stop: true}
297				}
298				return listInputResult{action: b}
299			},
300			wantStop: true, wantAction: 'x', wantCalls: []byte("x"),
301		},
302		{
303			name:  "custom exit and stop retain both flags and action",
304			input: []byte("xjy"),
305			custom: func(b byte) listInputResult {
306				if b == 'x' {
307					return listInputResult{action: b, exit: true, stop: true}
308				}
309				return listInputResult{action: b}
310			},
311			wantExit: true, wantStop: true, wantAction: 'x', wantCalls: []byte("x"),
312		},
313	} {
314		t.Run(tt.name, func(t *testing.T) {
315			var pending, calls []byte
316			var down int
317			state := listInputState{
318				pending:  &pending,
319				esc:      &pendingByteTimer{},
320				moveUp:   func() {},
321				moveDown: func() { down++ },
322			}
323
324			result := handleListInputLocked(nil, tt.input, state, func(b byte) listInputResult {
325				calls = append(calls, b)
326				return tt.custom(b)
327			})
328
329			require.Equal(t, tt.wantExit, result.exit)
330			require.Equal(t, tt.wantStop, result.stop)
331			require.Equal(t, tt.wantAction, result.action)
332			require.Equal(t, tt.wantCalls, calls)
333			require.Zero(t, down, "trailing j must not be processed")
334		})
335	}
336}
337
338func TestNoticesOpenWithEmptyHistoryShowsPlaceholderWithoutPanic(t *testing.T) {
339	p, release := newBlockingPTY(t)
340	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
341	defer release()
342
343	require.NotPanics(t, func() { d.enterNotices(sess, ac) })
344	out := awaitFrame(t, sends, "Output")
345
346	require.NotNil(t, ac.overlays.noticesOverlay)
347	msg := unmarshalTestOutput(t, out.Payload)
348	require.Contains(t, string(msg.Data), "Notifications")
349	require.Contains(t, string(msg.Data), "No notifications")
350}
351
352func TestNoticeYankPayload(t *testing.T) {
353	at := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)
354	tests := []struct {
355		name string
356		n    domain.Notification
357		want string
358	}{
359		{
360			name: "single occurrence, no details",
361			n:    domain.Notification{Code: domain.NoticePaneSpawn, Severity: domain.NoticeInfo, Message: "opened pane", Time: at},
362			want: "[2024-01-02T03:04:05Z] info pane-spawn\nopened pane\n",
363		},
364		{
365			name: "coalesced count and details",
366			n:    domain.Notification{Code: domain.NoticePaneSpawn, Severity: domain.NoticeError, Message: "couldn't open pane", Details: "boom ← permission denied", Count: 3, Time: at},
367			want: "[2024-01-02T03:04:05Z] error pane-spawn ×3\ncouldn't open pane\ndetails: boom ← permission denied\n",
368		},
369		{
370			name: "warn severity",
371			n:    domain.Notification{Code: domain.NoticeClipboard, Severity: domain.NoticeWarn, Message: "selection too large", Time: at},
372			want: "[2024-01-02T03:04:05Z] warn clipboard\nselection too large\n",
373		},
374	}
375	for _, tt := range tests {
376		t.Run(tt.name, func(t *testing.T) {
377			require.Equal(t, tt.want, noticeYankPayload(tt.n))
378		})
379	}
380}
381
382func TestYankLastNotificationCommandCopiesOSC52AndShowsFeedback(t *testing.T) {
383	p, release := newBlockingPTY(t)
384	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
385	defer release()
386	n := domain.Notification{Code: domain.NoticePaneSpawn, Severity: domain.NoticeError, Message: "couldn't open pane", Details: "boom", Time: time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)}
387	d.notices.record(n)
388	want := noticeYankPayload(n)
389
390	d.enterPalette(sess, ac)
391	awaitFrame(t, sends, "Output")
392	d.handlePaletteInput(ac, []byte("YLN\r"))
393
394	out := awaitFrame(t, sends, "Output")
395	msg := unmarshalTestOutput(t, out.Payload)
396	payload := string(msg.Data)
397	require.True(t, strings.HasPrefix(payload, "\x1b]52;c;"), "OSC52 payload = %q", payload)
398	require.True(t, strings.HasSuffix(payload, "\a"), "OSC52 payload = %q", payload)
399	decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(payload, "\x1b]52;c;"), "\a"))
400	require.NoError(t, err)
401	require.Equal(t, want, string(decoded))
402
403	live := awaitFrame(t, sends, "Output")
404	liveMsg := unmarshalTestOutput(t, live.Payload)
405	require.Contains(t, string(liveMsg.Data), "copied notification details")
406}
407
408func TestYankLastNotificationWithEmptyHistoryShowsWarnToast(t *testing.T) {
409	p, release := newBlockingPTY(t)
410	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
411	defer release()
412
413	d.enterPalette(sess, ac)
414	awaitFrame(t, sends, "Output")
415	d.handlePaletteInput(ac, []byte("YLN\r"))
416
417	out := awaitFrame(t, sends, "Output")
418	msg := unmarshalTestOutput(t, out.Payload)
419	require.Contains(t, string(msg.Data), "no notifications yet")
420
421	history := d.notices.history()
422	require.Len(t, history, 1)
423	require.Equal(t, domain.NoticeClipboard, history[0].Code)
424	require.Equal(t, domain.NoticeWarn, history[0].Severity)
425	require.Equal(t, "no notifications yet", history[0].Message)
426}
427
428func TestNoticesYKeyYanksSelectedNotification(t *testing.T) {
429	p, release := newBlockingPTY(t)
430	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
431	defer release()
432	d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Severity: domain.NoticeWarn, Message: "first", Time: time.Unix(1, 0)})
433	d.notices.record(domain.Notification{Code: domain.NoticeTabSpawn, Severity: domain.NoticeError, Message: "second", Time: time.Unix(2, 0)})
434
435	d.enterNotices(sess, ac)
436	awaitFrame(t, sends, "Output")
437	selected, ok := ac.overlays.noticesOverlay.Selected()
438	require.True(t, ok)
439	require.Equal(t, "second", selected.Message)
440	want := noticeYankPayload(selected)
441
442	d.handleInput(sess, ac, []byte("y"))
443
444	out := awaitFrame(t, sends, "Output")
445	msg := unmarshalTestOutput(t, out.Payload)
446	payload := string(msg.Data)
447	require.True(t, strings.HasPrefix(payload, "\x1b]52;c;"), "OSC52 payload = %q", payload)
448	decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(payload, "\x1b]52;c;"), "\a"))
449	require.NoError(t, err)
450	require.Equal(t, want, string(decoded))
451
452	// The overlay stays open after a quick yank; it isn't a modal commit like
453	// copy mode's own 'y'.
454	require.True(t, ac.overlays.noticesActive())
455}
456
457func TestNoticesBatchedYankCapturesSelectionAtY(t *testing.T) {
458	tests := []struct {
459		name         string
460		input        []byte
461		wantYanked   string
462		wantSelected string
463	}{
464		{name: "yank then down", input: []byte("yj"), wantYanked: "third", wantSelected: "second"},
465		{name: "down then yank", input: []byte("jy"), wantYanked: "second", wantSelected: "second"},
466	}
467	for _, tt := range tests {
468		t.Run(tt.name, func(t *testing.T) {
469			p, release := newBlockingPTY(t)
470			defer release()
471			d, sess, ac, sends := newManualSessionWithPTYs(t, p)
472			d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "first", Time: time.Unix(1, 0)})
473			d.notices.record(domain.Notification{Code: domain.NoticeTabSpawn, Message: "second", Time: time.Unix(2, 0)})
474			d.notices.record(domain.Notification{Code: domain.NoticeInternal, Message: "third", Time: time.Unix(3, 0)})
475
476			d.enterNotices(sess, ac)
477			awaitFrame(t, sends, "Output")
478			d.handleInput(sess, ac, tt.input)
479
480			out := awaitFrame(t, sends, "Output")
481			msg := unmarshalTestOutput(t, out.Payload)
482			payload := strings.TrimSuffix(strings.TrimPrefix(string(msg.Data), "\x1b]52;c;"), "\a")
483			decoded, err := base64.StdEncoding.DecodeString(payload)
484			require.NoError(t, err)
485			require.Contains(t, string(decoded), "\n"+tt.wantYanked+"\n")
486
487			selected, ok := ac.overlays.noticesOverlay.Selected()
488			require.True(t, ok)
489			require.Equal(t, tt.wantSelected, selected.Message)
490		})
491	}
492}
493
494func TestNoticesYKeyWithNoSelectionIsNoOp(t *testing.T) {
495	p, release := newBlockingPTY(t)
496	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
497	defer release()
498
499	require.NotPanics(t, func() { d.enterNotices(sess, ac) })
500	awaitFrame(t, sends, "Output")
501
502	require.NotPanics(t, func() { d.handleInput(sess, ac, []byte("y")) })
503	require.True(t, ac.overlays.noticesActive())
504}

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 12

Tags 11

↑↓ moveenter openesc close