notice_test.go RawHistoryFolder

1package ui
2
3import (
4	"fmt"
5	"strings"
6	"testing"
7
8	renderer "github.com/bnema/vev-vt"
9	"github.com/bnema/vev/internal/domain"
10)
11
12// frameText joins every row of frame into one newline-separated string so
13// tests can search for substrings without caring which cells they land on.
14func frameText(f renderer.Frame) string {
15	rows := make([]string, f.Height)
16	for y := 0; y < f.Height; y++ {
17		var b strings.Builder
18		for x := 0; x < f.Width; x++ {
19			cell := f.At(x, y)
20			if cell.Continuation {
21				continue
22			}
23			if cell.Rune == 0 {
24				b.WriteByte(' ')
25				continue
26			}
27			b.WriteRune(cell.Rune)
28		}
29		rows[y] = b.String()
30	}
31	return strings.Join(rows, "\n")
32}
33
34// firstRowContaining returns the index of the first row in f whose rendered
35// text contains s, or -1 if no row does.
36func firstRowContaining(f renderer.Frame, s string) int {
37	for y, row := range strings.Split(frameText(f), "\n") {
38		if strings.Contains(row, s) {
39			return y
40		}
41	}
42	return -1
43}
44
45func TestComposeNotices(t *testing.T) {
46	styles := NoticeStyles{
47		Text:     renderer.Style{Foreground: 7, Background: -1},
48		BoxInfo:  renderer.Style{Foreground: 4, Background: -1},
49		BoxWarn:  renderer.Style{Foreground: 3, Background: -1},
50		BoxError: renderer.Style{Foreground: 1, Background: -1},
51	}
52	isBorderRune := func(r rune) bool {
53		switch r {
54		case '┌', '┐', '└', '┘', '─', '│':
55			return true
56		default:
57			return false
58		}
59	}
60
61	tests := []struct {
62		name     string
63		notices  []NoticeView
64		overflow int
65		check    func(t *testing.T, f renderer.Frame)
66	}{
67		{
68			name: "single error notice draws a bordered box top-right with title and message",
69			notices: []NoticeView{
70				{Severity: domain.NoticeError, Title: "pane-spawn", Message: "couldn't open pane", Count: 3},
71			},
72			check: func(t *testing.T, f renderer.Frame) {
73				found := false
74				for y := 0; y <= 8 && !found; y++ {
75					for x := 40; x < f.Width; x++ {
76						if isBorderRune(f.At(x, y).Rune) {
77							found = true
78							break
79						}
80					}
81				}
82				if !found {
83					t.Fatalf("no box border found in top-right quadrant (x>=40, y<=8):\n%s", frameText(f))
84				}
85				text := frameText(f)
86				if !strings.Contains(text, "pane-spawn ×3") {
87					t.Fatalf("frame missing title %q:\n%s", "pane-spawn ×3", text)
88				}
89				if !strings.Contains(text, "couldn't open pane") {
90					t.Fatalf("frame missing message %q:\n%s", "couldn't open pane", text)
91				}
92			},
93		},
94		{
95			name: "three notices with overflow renders a +N more line",
96			notices: []NoticeView{
97				{Severity: domain.NoticeError, Title: "pane-spawn", Message: "one"},
98				{Severity: domain.NoticeWarn, Title: "tab-spawn", Message: "two"},
99				{Severity: domain.NoticeInfo, Title: "clipboard", Message: "three"},
100			},
101			overflow: 2,
102			check: func(t *testing.T, f renderer.Frame) {
103				text := frameText(f)
104				if !strings.Contains(text, "+2 more") {
105					t.Fatalf("frame missing overflow line %q:\n%s", "+2 more", text)
106				}
107				rowOne := firstRowContaining(f, "one")
108				rowTwo := firstRowContaining(f, "two")
109				rowThree := firstRowContaining(f, "three")
110				if rowOne < 0 || rowTwo < 0 || rowThree < 0 {
111					t.Fatalf("could not locate all three notice messages in frame: one=%d two=%d three=%d\n%s", rowOne, rowTwo, rowThree, text)
112				}
113				if rowOne >= rowTwo || rowTwo >= rowThree {
114					t.Fatalf("notices not stacked newest-first top-down: one=%d two=%d three=%d\n%s", rowOne, rowTwo, rowThree, text)
115				}
116			},
117		},
118		{
119			name:     "no notices and no overflow draws nothing",
120			notices:  nil,
121			overflow: 0,
122			check: func(t *testing.T, f renderer.Frame) {
123				want := renderer.NewFrame(f.Width, f.Height)
124				if frameText(f) != frameText(want) {
125					t.Fatalf("frame changed with no notices and no overflow:\n%s", frameText(f))
126				}
127			},
128		},
129	}
130
131	for _, tt := range tests {
132		t.Run(tt.name, func(t *testing.T) {
133			f := renderer.NewFrame(80, 24)
134			ComposeNotices(f, tt.notices, tt.overflow, styles)
135			tt.check(t, f)
136		})
137	}
138}
139
140func TestComposeNoticesSeverityStylingIsDistinguishable(t *testing.T) {
141	styles := NoticeStyles{
142		Text:     renderer.Style{Foreground: 7, Background: -1},
143		BoxInfo:  renderer.Style{Foreground: 4, Background: -1},
144		BoxWarn:  renderer.Style{Foreground: 3, Background: -1},
145		BoxError: renderer.Style{Foreground: 1, Background: -1},
146	}
147
148	tests := []struct {
149		name string
150		sev  domain.NoticeSeverity
151		want renderer.Style
152	}{
153		{"info", domain.NoticeInfo, styles.BoxInfo},
154		{"warn", domain.NoticeWarn, styles.BoxWarn},
155		{"error", domain.NoticeError, styles.BoxError},
156	}
157	for _, tt := range tests {
158		t.Run(tt.name, func(t *testing.T) {
159			f := renderer.NewFrame(80, 24)
160			ComposeNotices(f, []NoticeView{{Severity: tt.sev, Title: "x", Message: "y"}}, 0, styles)
161			corner := findRune(f, '┌')
162			if corner == nil {
163				t.Fatalf("no box top-left corner found:\n%s", frameText(f))
164			}
165			if !corner.Style.Equal(tt.want) {
166				t.Fatalf("box border style = %+v, want %s style %+v", corner.Style, tt.name, tt.want)
167			}
168		})
169	}
170}
171
172// findRune returns the first cell in f carrying r, or nil if none does.
173func findRune(f renderer.Frame, r rune) *renderer.Cell {
174	for y := 0; y < f.Height; y++ {
175		for x := 0; x < f.Width; x++ {
176			if cell := f.At(x, y); cell.Rune == r {
177				return &cell
178			}
179		}
180	}
181	return nil
182}
183
184// findRunePos returns the coordinates of the first cell in f carrying r, or
185// (-1, -1) if no cell does.
186func findRunePos(f renderer.Frame, r rune) (int, int) {
187	for y := 0; y < f.Height; y++ {
188		for x := 0; x < f.Width; x++ {
189			if f.At(x, y).Rune == r {
190				return x, y
191			}
192		}
193	}
194	return -1, -1
195}
196
197// TestComposeNoticesWidth pins the box's actual width and left edge across
198// representative terminal widths, so the test fails if the *2/5 ratio, the
199// 60-column cap, the 24-column floor, or the final frame-width clamp changes.
200// Each expected x and width below is worked out by hand from the formula
201// documented on ComposeNotices, not recomputed from the implementation.
202func TestComposeNoticesWidth(t *testing.T) {
203	styles := NoticeStyles{
204		Text:     renderer.Style{Foreground: 7, Background: -1},
205		BoxInfo:  renderer.Style{Foreground: 4, Background: -1},
206		BoxWarn:  renderer.Style{Foreground: 3, Background: -1},
207		BoxError: renderer.Style{Foreground: 1, Background: -1},
208	}
209
210	tests := []struct {
211		name       string
212		frameWidth int
213		wantWidth  int
214		wantX      int // -1 means the box's left edge falls off-frame (x < 0)
215	}{
216		// 200 cols: 200*2/5=80, capped at 60. x = 200 - 1(margin) - 60 = 139.
217		{name: "60-column cap binds on a wide terminal", frameWidth: 200, wantWidth: 60, wantX: 139},
218		// 100 cols: 100*2/5=40, under the cap and floor. x = 100 - 1 - 40 = 59.
219		{name: "cols*2/5 ratio binds on a mid terminal", frameWidth: 100, wantWidth: 40, wantX: 59},
220		// 40 cols: 40*2/5=16, floored to 24. x = 40 - 1 - 24 = 15.
221		{name: "24-column floor binds on a narrow terminal", frameWidth: 40, wantWidth: 24, wantX: 15},
222		// 20 cols: 20*2/5=8, floored to 24, then clamped down to frame.Width
223		// (20) since 24 > 20. x = 20 - 1(margin) - 20 = -1, so the left edge
224		// (and its corner rune) fall one column off-frame.
225		{name: "frame-width clamp overrides the floor on a tiny terminal", frameWidth: 20, wantWidth: 20, wantX: -1},
226	}
227
228	for _, tt := range tests {
229		t.Run(tt.name, func(t *testing.T) {
230			f := renderer.NewFrame(tt.frameWidth, 10)
231			ComposeNotices(f, []NoticeView{{Severity: domain.NoticeInfo, Title: "x", Message: "y"}}, 0, styles)
232
233			wantRight := tt.wantX + tt.wantWidth - 1
234			rightX, rightY := findRunePos(f, '┐')
235			if rightX < 0 {
236				t.Fatalf("no top-right corner found:\n%s", frameText(f))
237			}
238			if rightY != noticeMargin || rightX != wantRight {
239				t.Fatalf("top-right corner at (%d,%d), want (%d,%d):\n%s", rightX, rightY, wantRight, noticeMargin, frameText(f))
240			}
241
242			// The message body starts one column right of the box's left
243			// edge (wantX+1) and one row below its top edge. Pinning this
244			// rune's exact column catches width/x regressions that the
245			// top-right corner can't: right = x+width-1 = frame.Width-margin-1
246			// no matter what width is, so the right corner alone can't tell
247			// a correctly clamped box from one whose x drifted further
248			// off-frame (both still land off-screen).
249			msgX, msgY := tt.wantX+1, noticeMargin+1
250			if got := f.At(msgX, msgY).Rune; got != 'y' {
251				t.Fatalf("message rune at (%d,%d) = %q, want 'y':\n%s", msgX, msgY, got, frameText(f))
252			}
253
254			leftX, leftY := findRunePos(f, '┌')
255			if tt.wantX < 0 {
256				if leftX >= 0 {
257					t.Fatalf("expected left corner to be clipped off-frame, found one at (%d,%d):\n%s", leftX, leftY, frameText(f))
258				}
259				return
260			}
261			if leftX != tt.wantX || leftY != noticeMargin {
262				t.Fatalf("top-left corner at (%d,%d), want (%d,%d):\n%s", leftX, leftY, tt.wantX, noticeMargin, frameText(f))
263			}
264			if gotWidth := rightX - leftX + 1; gotWidth != tt.wantWidth {
265				t.Fatalf("box width = %d, want %d:\n%s", gotWidth, tt.wantWidth, frameText(f))
266			}
267		})
268	}
269}
270
271func TestComposeNoticesHandlesTinyFrames(t *testing.T) {
272	styles := NoticeStyles{Text: renderer.DefaultStyle(), BoxInfo: renderer.DefaultStyle(), BoxWarn: renderer.DefaultStyle(), BoxError: renderer.DefaultStyle()}
273	for _, size := range []domain.Size{{}, {Cols: 1, Rows: 1}, {Cols: 2, Rows: 1}, {Cols: 1, Rows: 2}, {Cols: 24, Rows: 1}} {
274		t.Run(fmt.Sprintf("%dx%d", size.Cols, size.Rows), func(t *testing.T) {
275			f := renderer.NewFrame(size.Cols, size.Rows)
276			notices := []NoticeView{{Severity: domain.NoticeError, Title: "pane-spawn", Message: "a very long message that should wrap across several lines of text"}}
277			ComposeNotices(f, notices, 5, styles)
278		})
279	}
280}

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