v0.1.1 7141aff

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

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