v0.3.0 99dc266

toast_test.go RawHistoryFolder

1package ui
2
3import (
4	"fmt"
5	"testing"
6	"time"
7
8	renderer "github.com/bnema/vev-vt"
9	"github.com/bnema/vev/internal/domain"
10)
11
12func TestToastManagerDurationDismissAndClear(t *testing.T) {
13	now := time.Date(2026, 7, 6, 12, 0, 0, 0, time.UTC)
14
15	t.Run("show returns active immediately", func(t *testing.T) {
16		m := NewToastManager()
17		m.Show(now, Toast{ID: "one", Message: "hello"})
18		active := m.Active(now)
19		if len(active) != 1 || active[0].ID != "one" {
20			t.Fatalf("Active() = %+v, want one active toast", active)
21		}
22	})
23
24	t.Run("zero duration remains until dismissed", func(t *testing.T) {
25		m := NewToastManager()
26		m.Show(now, Toast{ID: "persistent", Duration: 0})
27		if !m.HasActive(now.Add(time.Hour)) {
28			t.Fatalf("HasActive() = false, want persistent toast after one hour")
29		}
30	})
31
32	t.Run("positive duration expires at deadline", func(t *testing.T) {
33		m := NewToastManager()
34		m.Show(now, Toast{ID: "timed", Duration: time.Minute})
35		if !m.HasActive(now.Add(time.Minute - time.Nanosecond)) {
36			t.Fatalf("HasActive() before deadline = false, want true")
37		}
38		if m.HasActive(now.Add(time.Minute)) {
39			t.Fatalf("HasActive() at deadline = true, want false")
40		}
41		if got := m.Active(now.Add(2 * time.Minute)); len(got) != 0 {
42			t.Fatalf("Active() after deadline = %+v, want none", got)
43		}
44	})
45
46	t.Run("dismiss removes only matching toast", func(t *testing.T) {
47		m := NewToastManager()
48		m.Show(now, Toast{ID: "keep"})
49		m.Show(now, Toast{ID: "remove"})
50		m.Dismiss("remove")
51		active := m.Active(now)
52		if len(active) != 1 || active[0].ID != "keep" {
53			t.Fatalf("Active() after dismiss = %+v, want only keep", active)
54		}
55	})
56
57	t.Run("empty IDs use named anchors and replace per anchor", func(t *testing.T) {
58		anchors := []domain.Anchor{
59			domain.AnchorCenter, domain.AnchorTopLeft, domain.AnchorTop, domain.AnchorTopRight,
60			domain.AnchorLeft, domain.AnchorRight, domain.AnchorBottomLeft, domain.AnchorBottom, domain.AnchorBottomRight,
61		}
62		for _, anchor := range anchors {
63			m := NewToastManager()
64			m.Show(now, Toast{Message: "first", Anchor: anchor})
65			m.Show(now.Add(time.Second), Toast{Message: "second", Anchor: anchor})
66			active := m.Active(now.Add(time.Second))
67			if len(active) != 1 || active[0].Message != "second" || active[0].ID != "anchor:"+anchor.String() {
68				t.Fatalf("Active() for %s = %+v, want named replacement", anchor, active)
69			}
70		}
71	})
72
73	t.Run("clear removes all toasts", func(t *testing.T) {
74		m := NewToastManager()
75		m.Show(now, Toast{ID: "one"})
76		m.Show(now, Toast{ID: "two"})
77		m.Clear()
78		if got := m.Active(now); len(got) != 0 {
79			t.Fatalf("Active() after Clear() = %+v, want none", got)
80		}
81	})
82}
83
84func TestTruncateText(t *testing.T) {
85	tests := []struct {
86		name     string
87		text     string
88		maxWidth int
89		want     string
90	}{
91		{"no truncation needed", "hello", 10, "hello"},
92		{"truncation with ellipsis", "hello world", 8, "hello w…"},
93		{"maxWidth zero", "hello", 0, ""},
94		// The ellipsis itself is one cell wide, so any non-positive maxWidth is
95		// also "smaller than the ellipsis" and collapses to the same empty result.
96		{"maxWidth smaller than ellipsis", "hello world", -1, ""},
97		{"wide runes", "日本語のテキスト", 5, "日本…"},
98	}
99	for _, tt := range tests {
100		t.Run(tt.name, func(t *testing.T) {
101			if got := TruncateText(tt.text, tt.maxWidth); got != tt.want {
102				t.Fatalf("TruncateText(%q, %d) = %q, want %q", tt.text, tt.maxWidth, got, tt.want)
103			}
104		})
105	}
106}
107
108func TestToastBoundsAnchorsMarginsAndTinyFrames(t *testing.T) {
109	message := "Reconnection attempts…"
110	width := textWidth(message) + 4
111	if width != 26 {
112		t.Fatalf("test message width = %d, want 26", width)
113	}
114
115	tests := []struct {
116		name  string
117		base  domain.Size
118		toast Toast
119		want  domain.Rect
120	}{
121		{"top left", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorTopLeft}, domain.Rect{X: 2, Y: 2, Width: 26, Height: 3}},
122		{"top", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorTop}, domain.Rect{X: 27, Y: 2, Width: 26, Height: 3}},
123		{"top right", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorTopRight}, domain.Rect{X: 52, Y: 2, Width: 26, Height: 3}},
124		{"left", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorLeft}, domain.Rect{X: 2, Y: 10, Width: 26, Height: 3}},
125		{"center", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorCenter}, domain.Rect{X: 27, Y: 10, Width: 26, Height: 3}},
126		{"right", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorRight}, domain.Rect{X: 52, Y: 10, Width: 26, Height: 3}},
127		{"bottom left", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorBottomLeft}, domain.Rect{X: 2, Y: 19, Width: 26, Height: 3}},
128		{"bottom", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorBottom}, domain.Rect{X: 27, Y: 19, Width: 26, Height: 3}},
129		{"bottom right", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorBottomRight}, domain.Rect{X: 52, Y: 19, Width: 26, Height: 3}},
130		{"omitted anchor centers", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message}, domain.Rect{X: 27, Y: 10, Width: 26, Height: 3}},
131		{"zero frame", domain.Size{}, Toast{Message: "tiny"}, domain.Rect{}},
132		{"one by one clamps", domain.Size{Cols: 1, Rows: 1}, Toast{Message: "tiny"}, domain.Rect{Width: 1, Height: 1}},
133		{"narrow frame clamps width and margin", domain.Size{Cols: 8, Rows: 2}, Toast{Message: "long message", Anchor: domain.AnchorBottomRight}, domain.Rect{Width: 8, Height: 2}},
134	}
135
136	for _, tt := range tests {
137		t.Run(tt.name, func(t *testing.T) {
138			if got := ToastBounds(tt.base, tt.toast); got != tt.want {
139				t.Fatalf("ToastBounds() = %+v, want %+v", got, tt.want)
140			}
141		})
142	}
143}
144
145func TestCompositeToastsDrawsBorderedToastAndTruncates(t *testing.T) {
146	styles := ToastStyles{Text: renderer.Style{Foreground: 3, Background: -1}, Box: renderer.Style{Bold: true, Foreground: 4, Background: -1}}
147
148	t.Run("bordered centered toast preserves exterior", func(t *testing.T) {
149		f := renderer.NewFrame(20, 7)
150		exterior := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}
151		FillRect(f, domain.Rect{Width: 20, Height: 7}, exterior)
152
153		CompositeToasts(f, []ActiveToast{{Toast: Toast{ID: "center", Message: "Hi", Anchor: domain.AnchorCenter}}}, styles, nil)
154		bounds := ToastBounds(domain.Size{Cols: 20, Rows: 7}, Toast{Message: "Hi", Anchor: domain.AnchorCenter})
155		if bounds != (domain.Rect{X: 7, Y: 2, Width: 6, Height: 3}) {
156			t.Fatalf("bounds = %+v, want centered 6x3 box", bounds)
157		}
158		assertRune(t, f, bounds.X, bounds.Y, '┌')
159		assertRune(t, f, bounds.X+bounds.Width-1, bounds.Y, '┐')
160		assertRune(t, f, bounds.X, bounds.Y+bounds.Height-1, '└')
161		assertRune(t, f, bounds.X+bounds.Width-1, bounds.Y+bounds.Height-1, '┘')
162		assertRune(t, f, bounds.X+1, bounds.Y, '─')
163		assertRune(t, f, bounds.X, bounds.Y+1, '│')
164		assertRune(t, f, bounds.X+2, bounds.Y+1, 'H')
165		assertRune(t, f, bounds.X+3, bounds.Y+1, 'i')
166		assertCell(t, f, 0, 0, exterior)
167		assertCell(t, f, 19, 6, exterior)
168	})
169
170	t.Run("narrow toast truncates with ellipsis", func(t *testing.T) {
171		f := renderer.NewFrame(8, 3)
172		CompositeToasts(f, []ActiveToast{{Toast: Toast{ID: "narrow", Message: "abcdef", Anchor: domain.AnchorCenter, MaxWidth: 8}}}, styles, nil)
173		assertRune(t, f, 0, 0, '┌')
174		assertRune(t, f, 7, 0, '┐')
175		assertRune(t, f, 2, 1, 'a')
176		assertRune(t, f, 3, 1, 'b')
177		assertRune(t, f, 4, 1, 'c')
178		assertRune(t, f, 5, 1, '…')
179	})
180}
181
182func TestCompositeToastsLatestToastPerAnchor(t *testing.T) {
183	f := renderer.NewFrame(30, 5)
184	now := time.Date(2026, 7, 6, 12, 0, 0, 0, time.UTC)
185	styles := ToastStyles{Text: renderer.Style{Foreground: 3, Background: -1}, Box: renderer.Style{Foreground: 4, Background: -1}}
186
187	CompositeToasts(f, []ActiveToast{
188		{Toast: Toast{ID: "old", Message: "Old", Anchor: domain.AnchorTopLeft}, ShownAt: now},
189		{Toast: Toast{ID: "new", Message: "New", Anchor: domain.AnchorTopLeft}, ShownAt: now.Add(time.Second)},
190		{Toast: Toast{ID: "center", Message: "Mid", Anchor: domain.AnchorCenter}, ShownAt: now},
191	}, styles, nil)
192
193	assertRune(t, f, 4, 3, 'N')
194	assertRune(t, f, 5, 3, 'e')
195	assertRune(t, f, 6, 3, 'w')
196	for y := 0; y < f.Height; y++ {
197		for x := 0; x < f.Width; x++ {
198			if f.At(x, y).Rune == 'O' {
199				t.Fatalf("old toast rendered at (%d,%d), want only latest toast per anchor", x, y)
200			}
201		}
202	}
203}
204
205func TestLatestToastsByAnchorTieAndOrder(t *testing.T) {
206	now := time.Date(2026, 7, 6, 12, 0, 0, 0, time.UTC)
207	anchors := []domain.Anchor{
208		domain.AnchorTopLeft, domain.AnchorTopRight, domain.AnchorBottomLeft,
209		domain.AnchorBottomRight, domain.AnchorCenter, domain.AnchorTop,
210		domain.AnchorLeft, domain.AnchorRight, domain.AnchorBottom,
211	}
212	toasts := make([]ActiveToast, 0, len(anchors)+1)
213	for _, anchor := range anchors {
214		toasts = append(toasts, ActiveToast{Toast: Toast{ID: anchor.String(), Anchor: anchor}, ShownAt: now})
215	}
216	toasts = append(toasts, ActiveToast{Toast: Toast{ID: "later-input", Anchor: domain.AnchorTopLeft}, ShownAt: now})
217
218	visible := latestToastsByAnchor(toasts)
219	if len(visible) != len(anchors) {
220		t.Fatalf("latestToastsByAnchor() count = %d, want %d", len(visible), len(anchors))
221	}
222	for i, anchor := range anchors {
223		if visible[i].Anchor != anchor {
224			t.Fatalf("visible[%d].Anchor = %s, want %s", i, visible[i].Anchor, anchor)
225		}
226	}
227	if visible[0].ID != "later-input" {
228		t.Fatalf("equal-time tie selected %q, want later input", visible[0].ID)
229	}
230}
231
232func TestCompositeToastsDimsBackgroundWithoutClearingContent(t *testing.T) {
233	f := renderer.NewFrame(14, 5)
234	baseStyle := renderer.Style{Foreground: 1, Background: 2}
235	dimStyle := renderer.Style{Foreground: 8, Background: 9, Inverse: true}
236	for y := 0; y < f.Height; y++ {
237		for x := 0; x < f.Width; x++ {
238			f.Set(x, y, renderer.Cell{Rune: rune('a' + y), Style: baseStyle})
239		}
240	}
241	styles := ToastStyles{Text: renderer.Style{Foreground: 3, Background: -1}, Box: renderer.Style{Bold: true, Foreground: 4, Background: -1}}
242
243	CompositeToasts(f, []ActiveToast{{Toast: Toast{ID: "dim", Message: "Hi", Anchor: domain.AnchorCenter, DimBackground: true}}}, styles, func(renderer.Style) renderer.Style {
244		return dimStyle
245	})
246
247	outside := f.At(0, 0)
248	if outside.Rune != 'a' {
249		t.Fatalf("outside rune = %q, want preserved 'a'", outside.Rune)
250	}
251	if !outside.Style.Equal(dimStyle) {
252		t.Fatalf("outside style = %+v, want dimmed %+v", outside.Style, dimStyle)
253	}
254	bounds := ToastBounds(domain.Size{Cols: 14, Rows: 5}, Toast{Message: "Hi", Anchor: domain.AnchorCenter})
255	boxCell := f.At(bounds.X, bounds.Y)
256	if boxCell.Rune != '┌' || !boxCell.Style.Equal(styles.Box) {
257		t.Fatalf("box cell = %+v, want toast box style", boxCell)
258	}
259	textCell := f.At(bounds.X+2, bounds.Y+1)
260	if textCell.Rune != 'H' || !textCell.Style.Equal(styles.Text) {
261		t.Fatalf("text cell = %+v, want toast text style", textCell)
262	}
263}
264
265func TestCompositeToastsHandlesTinyFrames(t *testing.T) {
266	styles := ToastStyles{Text: renderer.DefaultStyle(), Box: renderer.DefaultStyle()}
267	for _, size := range []domain.Size{{}, {Cols: 1, Rows: 1}, {Cols: 2, Rows: 1}, {Cols: 1, Rows: 2}} {
268		t.Run(fmt.Sprintf("%dx%d", size.Cols, size.Rows), func(t *testing.T) {
269			f := renderer.NewFrame(size.Cols, size.Rows)
270			CompositeToasts(f, []ActiveToast{{Toast: Toast{Message: "abcdef", Anchor: domain.AnchorCenter}}}, styles, nil)
271		})
272	}
273}

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