v0.12.3 38097d7

session_dialog_test.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"strings"
6	"sync"
7	"testing"
8	"time"
9	"unicode"
10
11	"github.com/bnema/zut/packages/core"
12	"github.com/bnema/zut/packages/provider"
13	"github.com/bnema/zut/packages/tui"
14)
15
16const sessionDialogTestTimeout = 10 * time.Second
17
18func TestFormatSessionRowPlainSanitizesControlBytes(t *testing.T) {
19	row := formatSessionRowPlain(core.SessionSummary{
20		Provider:      "test\x1b]0;bad\a",
21		Model:         "model\x1b[31m",
22		MessageCount:  1,
23		FirstUserText: "hello\x1b[2J\nworld",
24	}, 120)
25	if strings.IndexFunc(row, unicode.IsControl) >= 0 {
26		t.Fatalf("session row contains control characters: %q", row)
27	}
28	if !strings.Contains(row, "hello world") {
29		t.Fatalf("sanitized session text missing: %q", row)
30	}
31}
32
33func TestSessionDialogLoadsEntriesWithoutBlockingOpen(t *testing.T) {
34	root := t.TempDir()
35	cwd := t.TempDir()
36	session, err := core.NewSession(root, cwd, "test", "test-model", "test-version")
37	if err != nil {
38		t.Fatal(err)
39	}
40	if err := session.AppendMessage(provider.Message{
41		Role:    provider.RoleUser,
42		Content: []provider.Content{provider.TextBlock{Text: "load this session"}},
43	}); err != nil {
44		t.Fatal(err)
45	}
46	if err := session.Close(); err != nil {
47		t.Fatal(err)
48	}
49
50	d := newSessionDialog()
51	events := d.Open(context.Background(), root, cwd)
52	t.Cleanup(d.Close)
53	if !d.Active() || !d.Loading() {
54		t.Fatalf("dialog state after Open = active %v, loading %v; want active and loading", d.Active(), d.Loading())
55	}
56	loadingText := strings.Join(d.Render(tui.Dark, 100), "\n")
57	if !strings.Contains(loadingText, "Loading sessions") {
58		t.Fatalf("loading render = %q, want spinner status", loadingText)
59	}
60	act := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
61	if act.Select || !d.Active() {
62		t.Fatalf("enter while loading = %+v, active %v; want no selection and active dialog", act, d.Active())
63	}
64
65	for event := range events {
66		d.ApplyLoad(event)
67	}
68
69	if d.Loading() {
70		t.Fatal("dialog still loading after final result")
71	}
72	if len(d.sessions) != 1 || d.sessions[0].Path != session.Path {
73		t.Fatalf("loaded sessions = %+v, want %q", d.sessions, session.Path)
74	}
75	loadedText := strings.Join(d.Render(tui.Dark, 100), "\n")
76	if strings.Contains(loadedText, "Loading sessions") {
77		t.Fatalf("completed render still shows loading spinner: %q", loadedText)
78	}
79	if !strings.Contains(loadedText, "load this session") {
80		t.Fatalf("completed render missing session text: %q", loadedText)
81	}
82}
83
84func TestSessionDialogCanceledParentDoesNotRemainLoading(t *testing.T) {
85	ctx, cancel := context.WithCancel(context.Background())
86	cancel()
87
88	d := newSessionDialog()
89	events := d.Open(ctx, t.TempDir(), t.TempDir())
90	if d.Loading() {
91		t.Fatal("dialog remained loading with an already-canceled parent")
92	}
93	if d.Active() {
94		t.Fatal("dialog remained active with an already-canceled parent")
95	}
96	if _, ok := <-events; ok {
97		t.Fatal("canceled dialog emitted a load event")
98	}
99}
100
101func TestSessionDialogCancellationFinishesLoading(t *testing.T) {
102	ctx, cancel := context.WithCancel(context.Background())
103	d := newSessionDialog()
104	events := d.Open(ctx, t.TempDir(), t.TempDir())
105	cancel()
106	for range events {
107	}
108	d.ApplyLoadClosed()
109	if d.Loading() {
110		t.Fatal("dialog remained loading after parent cancellation")
111	}
112	if d.Active() {
113		t.Fatal("dialog remained active after parent cancellation")
114	}
115}
116
117func TestSessionDialogCancellationDoesNotEmitFinished(t *testing.T) {
118	root := t.TempDir()
119	cwd := t.TempDir()
120	session, err := core.NewSession(root, cwd, "test", "test-model", "test-version")
121	if err != nil {
122		t.Fatal(err)
123	}
124	if err := session.AppendMessage(provider.Message{
125		Role:    provider.RoleUser,
126		Content: []provider.Content{provider.TextBlock{Text: "cancel this load"}},
127	}); err != nil {
128		t.Fatal(err)
129	}
130	if err := session.Close(); err != nil {
131		t.Fatal(err)
132	}
133
134	entered := make(chan struct{})
135	d := newSessionDialog()
136	d.describeSession = func(ctx context.Context, path string) core.SessionSummary {
137		close(entered)
138		<-ctx.Done()
139		return core.SessionSummary{Path: path}
140	}
141	ctx, cancel := context.WithCancel(context.Background())
142	events := d.Open(ctx, root, cwd)
143	t.Cleanup(func() {
144		cancel()
145		d.Close()
146	})
147
148	select {
149	case event, ok := <-events:
150		if !ok || event.kind != sessionLoadStarted {
151			t.Fatalf("first load event = %+v, open %v; want sessionLoadStarted", event, ok)
152		}
153		d.ApplyLoad(event)
154	case <-time.After(sessionDialogTestTimeout):
155		t.Fatal("timed out waiting for sessionLoadStarted")
156	}
157	select {
158	case <-entered:
159	case <-time.After(sessionDialogTestTimeout):
160		t.Fatal("timed out waiting for the session worker")
161	}
162
163	cancel()
164	for {
165		select {
166		case event, ok := <-events:
167			if !ok {
168				d.ApplyLoadClosed()
169				if d.Loading() || d.Active() {
170					t.Fatalf("dialog after cancellation = loading %v, active %v; want both false", d.Loading(), d.Active())
171				}
172				return
173			}
174			if event.kind == sessionLoadFinished {
175				t.Fatal("canceled load emitted sessionLoadFinished")
176			}
177			d.ApplyLoad(event)
178		case <-time.After(sessionDialogTestTimeout):
179			t.Fatal("timed out waiting for canceled session load to close")
180		}
181	}
182}
183
184func TestSessionDialogPreCanceledOpenPreservesPreviousLoadBarrier(t *testing.T) {
185	root := t.TempDir()
186	cwd := t.TempDir()
187	session, err := core.NewSession(root, cwd, "test", "test-model", "test-version")
188	if err != nil {
189		t.Fatal(err)
190	}
191	if err := session.AppendMessage(provider.Message{
192		Role:    provider.RoleUser,
193		Content: []provider.Content{provider.TextBlock{Text: "wait for the prior load"}},
194	}); err != nil {
195		t.Fatal(err)
196	}
197	if err := session.Close(); err != nil {
198		t.Fatal(err)
199	}
200
201	entered := make(chan struct{})
202	release := make(chan struct{})
203	var enteredOnce sync.Once
204	var releaseOnce sync.Once
205	releaseLoad := func() { releaseOnce.Do(func() { close(release) }) }
206	d := newSessionDialog()
207	d.describeSession = func(ctx context.Context, path string) core.SessionSummary {
208		enteredOnce.Do(func() { close(entered) })
209		<-release
210		return core.SessionSummary{Path: path, MessageCount: 1}
211	}
212	firstCtx, firstCancel := context.WithCancel(context.Background())
213	firstEvents := d.Open(firstCtx, root, cwd)
214	t.Cleanup(func() {
215		firstCancel()
216		releaseLoad()
217		d.Close()
218	})
219
220	select {
221	case event, ok := <-firstEvents:
222		if !ok || event.kind != sessionLoadStarted {
223			t.Fatalf("first load event = %+v, open %v; want sessionLoadStarted", event, ok)
224		}
225		d.ApplyLoad(event)
226	case <-time.After(sessionDialogTestTimeout):
227		t.Fatal("timed out waiting for the first load to start")
228	}
229	select {
230	case <-entered:
231	case <-time.After(sessionDialogTestTimeout):
232		t.Fatal("timed out waiting for the first worker")
233	}
234
235	preCanceled, cancelPreCanceled := context.WithCancel(context.Background())
236	cancelPreCanceled()
237	preCanceledEvents := d.Open(preCanceled, root, cwd)
238	if _, ok := <-preCanceledEvents; ok {
239		t.Fatal("pre-canceled open emitted a load event")
240	}
241
242	thirdCtx, cancelThird := context.WithCancel(context.Background())
243	thirdEvents := d.Open(thirdCtx, root, cwd)
244	select {
245	case event, ok := <-thirdEvents:
246		t.Fatalf("third open started before the first load finished: %+v, open %v", event, ok)
247	case <-time.After(100 * time.Millisecond):
248	}
249
250	releaseLoad()
251	firstCancel()
252	select {
253	case event, ok := <-thirdEvents:
254		if !ok || event.kind != sessionLoadStarted {
255			t.Fatalf("third load event = %+v, open %v; want sessionLoadStarted after barrier", event, ok)
256		}
257		d.ApplyLoad(event)
258	case <-time.After(sessionDialogTestTimeout):
259		t.Fatal("timed out waiting for third load after releasing the first")
260	}
261	cancelThird()
262	for event := range thirdEvents {
263		d.ApplyLoad(event)
264	}
265}
266
267func TestSessionDialogEscapeCancelsLoading(t *testing.T) {
268	d := newSessionDialog()
269	events := d.Open(context.Background(), t.TempDir(), t.TempDir())
270	act := d.HandleKey(tui.Key{Kind: tui.KeyEsc})
271	if !act.Close || d.Active() || d.Loading() {
272		t.Fatalf("escape action = %+v, active %v, loading %v; want closed dialog", act, d.Active(), d.Loading())
273	}
274	for range events {
275	}
276}
277
278func TestSessionDialogAppliesLoadedEntriesIncrementallyInPathOrder(t *testing.T) {
279	d := newSessionDialog()
280	d.active = true
281	d.loading = true
282	d.loadGeneration = 7
283
284	d.ApplyLoad(sessionLoadEvent{
285		kind:       sessionLoadStarted,
286		generation: 7,
287		total:      4,
288	})
289	d.ApplyLoad(sessionLoadEvent{
290		kind:       sessionLoadEntry,
291		generation: 7,
292		index:      2,
293		summary:    core.SessionSummary{Path: "third", MessageCount: 1},
294	})
295	if len(d.sessions) != 0 {
296		t.Fatalf("older out-of-order entry appeared before newest entry: %+v", d.sessions)
297	}
298	d.ApplyLoad(sessionLoadEvent{
299		kind:       sessionLoadEntry,
300		generation: 7,
301		index:      1,
302		summary:    core.SessionSummary{Path: "hidden", MessageCount: 1, HideFromSessions: true},
303	})
304	d.ApplyLoad(sessionLoadEvent{
305		kind:       sessionLoadEntry,
306		generation: 7,
307		index:      3,
308		summary:    core.SessionSummary{Path: "empty"},
309	})
310	d.ApplyLoad(sessionLoadEvent{
311		kind:       sessionLoadEntry,
312		generation: 7,
313		index:      0,
314		summary:    core.SessionSummary{Path: "first", MessageCount: 1},
315	})
316	if !d.Loading() {
317		t.Fatal("dialog stopped loading before the terminal event")
318	}
319	if len(d.sessions) != 2 || d.sessions[0].Path != "first" || d.sessions[1].Path != "third" {
320		t.Fatalf("incremental sessions = %+v, want first then third", d.sessions)
321	}
322
323	d.ApplyLoad(sessionLoadEvent{kind: sessionLoadFinished, generation: 7})
324
325	if d.Loading() {
326		t.Fatal("dialog still loading after final result")
327	}
328	if len(d.sessions) != 2 || d.sessions[0].Path != "first" || d.sessions[1].Path != "third" {
329		t.Fatalf("loaded sessions = %+v, want first then third", d.sessions)
330	}
331}
332
333func TestSessionDialogCanSelectLoadedEntryWhileLoading(t *testing.T) {
334	d := newSessionDialog()
335	d.active = true
336	d.loading = true
337	d.loadGeneration = 9
338
339	d.ApplyLoad(sessionLoadEvent{
340		kind:       sessionLoadStarted,
341		generation: 9,
342		total:      2,
343	})
344	d.ApplyLoad(sessionLoadEvent{
345		kind:       sessionLoadEntry,
346		generation: 9,
347		index:      0,
348		summary:    core.SessionSummary{Path: "newest", MessageCount: 1, FirstUserText: "recent work"},
349	})
350
351	if !d.Loading() {
352		t.Fatal("dialog stopped loading after first entry")
353	}
354	rendered := strings.Join(d.Render(tui.Dark, 100), "\n")
355	if !strings.Contains(rendered, "recent work") {
356		t.Fatalf("incremental render missing newest entry: %q", rendered)
357	}
358
359	act := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
360	if !act.Select || act.Path != "newest" || d.Active() {
361		t.Fatalf("select while loading = %+v, active %v; want newest selected and dialog closed", act, d.Active())
362	}
363}
364
365func TestSessionDialogIgnoresStaleLoadResults(t *testing.T) {
366	first := newSessionDialog()
367	firstEvents := first.Open(context.Background(), t.TempDir(), t.TempDir())
368	firstGeneration := first.loadGeneration
369
370	secondEvents := first.Open(context.Background(), t.TempDir(), t.TempDir())
371	secondGeneration := first.loadGeneration
372	if secondGeneration == firstGeneration {
373		t.Fatal("reopening picker did not advance load generation")
374	}
375	first.ApplyLoad(sessionLoadEvent{
376		kind:       sessionLoadStarted,
377		generation: secondGeneration,
378		total:      1,
379	})
380
381	first.ApplyLoad(sessionLoadEvent{
382		kind:       sessionLoadEntry,
383		generation: firstGeneration,
384		index:      0,
385		summary: core.SessionSummary{
386			Path:         "stale",
387			MessageCount: 1,
388		},
389	})
390	if len(first.sessions) != 0 {
391		t.Fatalf("stale results repopulated picker: %+v", first.sessions)
392	}
393
394	first.Close()
395	for range firstEvents {
396	}
397	for range secondEvents {
398	}
399}

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