v0.4.5 885ddb0

settings_dialog_test.go RawHistoryFolder

1package modes
2
3import (
4	"bytes"
5	"fmt"
6	"io"
7	"strings"
8	"testing"
9	"time"
10
11	"github.com/patriceckhart/zot/packages/tui"
12)
13
14// settingsTestItems builds a list long enough to overflow a short
15// terminal. Every entry carries a description that wraps, so an entry
16// costs more than one row and the window has to account for it.
17func settingsTestItems(n int) []settingsItem {
18	items := make([]settingsItem, 0, n)
19	for i := 0; i < n; i++ {
20		items = append(items, settingsItem{
21			key:   fmt.Sprintf("item_%02d", i),
22			label: fmt.Sprintf("entry %02d", i),
23			desc:  "description text that wraps onto several rows at the widths these tests render so every entry costs more than a single line",
24		})
25	}
26	return items
27}
28
29func renderedPlain(lines []string) string { return stripANSIBytes(strings.Join(lines, "\n")) }
30
31func TestSettingsDialogWindowsListToMaxRows(t *testing.T) {
32	d := newSettingsDialog()
33	d.Open(settingsTestItems(20))
34	d.MaxRows = 12
35
36	lines := d.Render(tui.Dark, 80)
37	if len(lines) > d.MaxRows {
38		t.Fatalf("rendered %d rows, want <= %d", len(lines), d.MaxRows)
39	}
40	text := renderedPlain(lines)
41	for _, want := range []string{"entry 00", "entry 01", "more below"} {
42		if !strings.Contains(text, want) {
43			t.Fatalf("top of window missing %q:\n%s", want, text)
44		}
45	}
46	if strings.Contains(text, "entry 19") {
47		t.Fatalf("window rendered the far tail of the list:\n%s", text)
48	}
49}
50
51func TestSettingsDialogWindowFollowsCursorToBothEnds(t *testing.T) {
52	d := newSettingsDialog()
53	d.Open(settingsTestItems(20))
54	d.MaxRows = 10
55
56	for i := 1; i < 20; i++ {
57		d.HandleKey(tui.Key{Kind: tui.KeyDown})
58		lines := d.Render(tui.Dark, 80)
59		if len(lines) > d.MaxRows {
60			t.Fatalf("cursor at %d: rendered %d rows, want <= %d", i, len(lines), d.MaxRows)
61		}
62		if text := renderedPlain(lines); !strings.Contains(text, fmt.Sprintf("entry %02d", i)) {
63			t.Fatalf("cursor entry %02d scrolled out of view:\n%s", i, text)
64		}
65	}
66	top := d.Render(tui.Dark, 80)
67	if text := renderedPlain(top); !strings.Contains(text, "more above") {
68		t.Fatalf("bottom of list missing the above marker:\n%s", text)
69	}
70
71	for i := 18; i >= 0; i-- {
72		d.HandleKey(tui.Key{Kind: tui.KeyUp})
73		lines := d.Render(tui.Dark, 80)
74		if text := renderedPlain(lines); !strings.Contains(text, fmt.Sprintf("entry %02d", i)) {
75			t.Fatalf("cursor entry %02d scrolled out of view on the way back:\n%s", i, text)
76		}
77	}
78	if text := renderedPlain(d.Render(tui.Dark, 80)); !strings.Contains(text, "entry 00") {
79		t.Fatalf("first entry not visible after scrolling back up:\n%s", text)
80	}
81}
82
83func TestSettingsDialogOptionsWindowFollowsCursor(t *testing.T) {
84	item := settingsItem{key: "theme", label: "color theme"}
85	for i := 0; i < 30; i++ {
86		item.options = append(item.options, settingsOption{
87			value: fmt.Sprintf("opt_%02d", i),
88			label: fmt.Sprintf("option %02d", i),
89			desc:  "option description that also wraps onto several rows at the widths these tests render",
90		})
91	}
92	d := newSettingsDialog()
93	d.OpenDirectOption(item)
94	d.MaxRows = 14
95
96	for i := 1; i < 30; i++ {
97		d.HandleKey(tui.Key{Kind: tui.KeyDown})
98		lines := d.Render(tui.Dark, 80)
99		if len(lines) > d.MaxRows {
100			t.Fatalf("option cursor at %d: rendered %d rows, want <= %d", i, len(lines), d.MaxRows)
101		}
102		if text := renderedPlain(lines); !strings.Contains(text, fmt.Sprintf("option %02d", i)) {
103			t.Fatalf("option %02d scrolled out of view:\n%s", i, text)
104		}
105	}
106	last := d.Render(tui.Dark, 80)
107	if text := renderedPlain(last); !strings.Contains(text, "more above") || !strings.Contains(text, "option 29") {
108		t.Fatalf("bottom of option list not windowed on the cursor:\n%s", text)
109	}
110}
111
112func TestSettingsDialogKeepsCursorRowOnTinyTerminal(t *testing.T) {
113	d := newSettingsDialog()
114	d.Open(settingsTestItems(20))
115	// Four rows is the dialog's floor: header, hint, one row of list,
116	// rule. The cursor entry must still be the row that survives.
117	d.MaxRows = 4
118	for i := 0; i < 20; i++ {
119		lines := d.Render(tui.Dark, 80)
120		if len(lines) > d.MaxRows {
121			t.Fatalf("cursor at %d: rendered %d rows, want <= %d", i, len(lines), d.MaxRows)
122		}
123		if text := renderedPlain(lines); !strings.Contains(text, fmt.Sprintf("entry %02d", i)) {
124			t.Fatalf("cursor entry %02d missing at the row floor:\n%s", i, text)
125		}
126		d.HandleKey(tui.Key{Kind: tui.KeyDown})
127	}
128}
129
130func TestSettingsOptionPickerFitsShortTerminal(t *testing.T) {
131	for _, rows := range []int{12, 14, 16, 17, 24} {
132		t.Run(fmt.Sprintf("rows=%d", rows), func(t *testing.T) {
133			term := &shortTestTerminal{cols: 80, rows: rows}
134			i := NewInteractive(InteractiveConfig{Terminal: term})
135			i.settingsDialog.OpenDirectOption(settingsItem{
136				label: "reasoning level", desc: "reasoning depth for reasoning-capable models",
137				options: []settingsOption{{label: "off"}, {label: "low"}},
138			})
139			i.redraw()
140
141			lines := i.settingsDialog.Render(i.cfg.Theme, 80)
142			if len(lines) > i.settingsDialog.MaxRows {
143				t.Fatalf("option picker rendered %d rows, cap %d", len(lines), i.settingsDialog.MaxRows)
144			}
145			text := renderedPlain(lines)
146			if !strings.Contains(text, "off") {
147				t.Fatalf("selected option missing from window:\n%s", text)
148			}
149			if i.settingsDialog.MaxRows >= 5 && !strings.Contains(text, "reasoning depth") {
150				t.Fatalf("description missing when it fits:\n%s", text)
151			}
152			written := strings.Count(term.String(), "\r\n") + 1
153			band := written - len(i.cachedChatLocked(80))
154			if band > rows-1 {
155				t.Fatalf("bottom band is %d rows for a %d-row terminal", band, rows)
156			}
157		})
158	}
159}
160
161func TestSettingsDialogRendersEverythingWithoutMaxRows(t *testing.T) {
162	d := newSettingsDialog()
163	d.Open(settingsTestItems(20))
164
165	text := renderedPlain(d.Render(tui.Dark, 80))
166	for _, want := range []string{"entry 00", "entry 19"} {
167		if !strings.Contains(text, want) {
168			t.Fatalf("unbounded render missing %q:\n%s", want, text)
169		}
170	}
171	if strings.Contains(text, "more above") || strings.Contains(text, "more below") {
172		t.Fatalf("unbounded render drew window markers:\n%s", text)
173	}
174}
175
176// shortTestTerminal is a Terminal with a configurable size so the
177// layout can be exercised at heights a real window would report.
178type shortTestTerminal struct {
179	bytes.Buffer
180	cols, rows int
181}
182
183func (t *shortTestTerminal) Size() (int, int)       { return t.cols, t.rows }
184func (t *shortTestTerminal) OnResize(func())        {}
185func (t *shortTestTerminal) SetNonblock(bool) error { return nil }
186func (t *shortTestTerminal) ReadByte() (byte, error) {
187	return 0, io.EOF
188}
189func (t *shortTestTerminal) EnterRaw() (func() error, error) {
190	return func() error { return nil }, nil
191}
192func (t *shortTestTerminal) PeekByteTimeout(time.Duration) (byte, bool, error) {
193	return 0, false, io.EOF
194}
195
196func TestSettingsDialogIsCappedToShortTerminal(t *testing.T) {
197	for _, rows := range []int{12, 14, 16, 24, 40, 60} {
198		t.Run(fmt.Sprintf("rows=%d", rows), func(t *testing.T) {
199			term := &shortTestTerminal{cols: 80, rows: rows}
200			i := NewInteractive(InteractiveConfig{Terminal: term})
201			i.openSettingsDialog()
202			if !i.settingsDialog.Active() {
203				t.Fatal("settings dialog did not open")
204			}
205			i.redraw()
206
207			if i.settingsDialog.MaxRows <= 0 || i.settingsDialog.MaxRows >= term.rows {
208				t.Fatalf("dialog cap = %d rows for a %d-row terminal", i.settingsDialog.MaxRows, term.rows)
209			}
210			lines := i.settingsDialog.Render(i.cfg.Theme, 80)
211			if len(lines) > i.settingsDialog.MaxRows {
212				t.Fatalf("dialog rendered %d rows, cap %d", len(lines), i.settingsDialog.MaxRows)
213			}
214			text := renderedPlain(lines)
215			if !strings.Contains(text, "render images when supported") {
216				t.Fatalf("first settings entry missing from the window:\n%s", text)
217			}
218
219			// The band the dialog draws into must fit above the
220			// terminal's bottom edge: written lines are the chat, the
221			// bottom band, and the renderer's trailing margin row. A
222			// taller band scrolls the top of the dialog out of reach
223			// again.
224			written := strings.Count(term.String(), "\r\n") + 1
225			band := written - len(i.cachedChatLocked(80))
226			if band > term.rows-1 {
227				t.Fatalf("bottom band is %d rows for a %d-row terminal; dialog would be clipped", band, term.rows)
228			}
229		})
230	}
231}

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 1

Tags 477

↑↓ moveenter openesc close