v0.2.0 80c61e3

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

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