v0.38.0 58133ea

session_dialog_test.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"os"
6	"path/filepath"
7	"strings"
8	"sync"
9	"testing"
10	"time"
11	"unicode"
12
13	"github.com/bnema/zut/packages/core"
14	"github.com/bnema/zut/packages/provider"
15	"github.com/bnema/zut/packages/tui"
16)
17
18const sessionDialogTestTimeout = 10 * time.Second
19
20func TestFormatSessionRowPlainSanitizesControlBytes(t *testing.T) {
21	row := formatSessionRowPlain(core.SessionSummary{
22		Provider:      "test\x1b]0;bad\a",
23		Model:         "model\x1b[31m",
24		MessageCount:  1,
25		FirstUserText: "hello\x1b[2J\nworld",
26	}, 120)
27	if strings.IndexFunc(row, unicode.IsControl) >= 0 {
28		t.Fatalf("session row contains control characters: %q", row)
29	}
30	if !strings.Contains(row, "hello world") {
31		t.Fatalf("sanitized session text missing: %q", row)
32	}
33}
34
35func TestSessionDialogLoadsEntriesWithoutBlockingOpen(t *testing.T) {
36	root := t.TempDir()
37	cwd := t.TempDir()
38	session, err := core.NewSession(root, cwd, "test", "test-model", "test-version")
39	if err != nil {
40		t.Fatal(err)
41	}
42	if err := session.AppendMessage(provider.Message{
43		Role:    provider.RoleUser,
44		Content: []provider.Content{provider.TextBlock{Text: "load this session"}},
45	}); err != nil {
46		t.Fatal(err)
47	}
48	if err := session.Close(); err != nil {
49		t.Fatal(err)
50	}
51
52	d := newSessionDialog()
53	events := d.Open(context.Background(), root, cwd)
54	t.Cleanup(d.Close)
55	if !d.Active() || !d.Loading() {
56		t.Fatalf("dialog state after Open = active %v, loading %v; want active and loading", d.Active(), d.Loading())
57	}
58	loadingText := strings.Join(d.Render(tui.Dark, 100), "\n")
59	if !strings.Contains(loadingText, "Loading sessions") {
60		t.Fatalf("loading render = %q, want spinner status", loadingText)
61	}
62	act := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
63	if act.Select || !d.Active() {
64		t.Fatalf("enter while loading = %+v, active %v; want no selection and active dialog", act, d.Active())
65	}
66
67	for event := range events {
68		d.ApplyLoad(event)
69	}
70
71	if d.Loading() {
72		t.Fatal("dialog still loading after final result")
73	}
74	if len(d.sessions) != 1 || d.sessions[0].Path != session.Path {
75		t.Fatalf("loaded sessions = %+v, want %q", d.sessions, session.Path)
76	}
77	loadedText := strings.Join(d.Render(tui.Dark, 100), "\n")
78	if strings.Contains(loadedText, "Loading sessions") {
79		t.Fatalf("completed render still shows loading spinner: %q", loadedText)
80	}
81	if !strings.Contains(loadedText, "load this session") {
82		t.Fatalf("completed render missing session text: %q", loadedText)
83	}
84}
85
86func TestSessionDialogCanceledParentDoesNotRemainLoading(t *testing.T) {
87	ctx, cancel := context.WithCancel(context.Background())
88	cancel()
89
90	d := newSessionDialog()
91	events := d.Open(ctx, t.TempDir(), t.TempDir())
92	if d.Loading() {
93		t.Fatal("dialog remained loading with an already-canceled parent")
94	}
95	if d.Active() {
96		t.Fatal("dialog remained active with an already-canceled parent")
97	}
98	if _, ok := <-events; ok {
99		t.Fatal("canceled dialog emitted a load event")
100	}
101}
102
103func TestSessionDialogCancellationFinishesLoading(t *testing.T) {
104	ctx, cancel := context.WithCancel(context.Background())
105	d := newSessionDialog()
106	events := d.Open(ctx, t.TempDir(), t.TempDir())
107	cancel()
108	for range events {
109	}
110	d.ApplyLoadClosed()
111	if d.Loading() {
112		t.Fatal("dialog remained loading after parent cancellation")
113	}
114	if d.Active() {
115		t.Fatal("dialog remained active after parent cancellation")
116	}
117}
118
119func TestSessionDialogCancellationDoesNotEmitFinished(t *testing.T) {
120	root := t.TempDir()
121	cwd := t.TempDir()
122	session, err := core.NewSession(root, cwd, "test", "test-model", "test-version")
123	if err != nil {
124		t.Fatal(err)
125	}
126	if err := session.AppendMessage(provider.Message{
127		Role:    provider.RoleUser,
128		Content: []provider.Content{provider.TextBlock{Text: "cancel this load"}},
129	}); err != nil {
130		t.Fatal(err)
131	}
132	if err := session.Close(); err != nil {
133		t.Fatal(err)
134	}
135
136	entered := make(chan struct{})
137	d := newSessionDialog()
138	d.describeSession = func(ctx context.Context, path string) core.SessionSummary {
139		close(entered)
140		<-ctx.Done()
141		return core.SessionSummary{Path: path}
142	}
143	ctx, cancel := context.WithCancel(context.Background())
144	events := d.Open(ctx, root, cwd)
145	t.Cleanup(func() {
146		cancel()
147		d.Close()
148	})
149
150	select {
151	case event, ok := <-events:
152		if !ok || event.kind != sessionLoadStarted {
153			t.Fatalf("first load event = %+v, open %v; want sessionLoadStarted", event, ok)
154		}
155		d.ApplyLoad(event)
156	case <-time.After(sessionDialogTestTimeout):
157		t.Fatal("timed out waiting for sessionLoadStarted")
158	}
159	select {
160	case <-entered:
161	case <-time.After(sessionDialogTestTimeout):
162		t.Fatal("timed out waiting for the session worker")
163	}
164
165	cancel()
166	for {
167		select {
168		case event, ok := <-events:
169			if !ok {
170				d.ApplyLoadClosed()
171				if d.Loading() || d.Active() {
172					t.Fatalf("dialog after cancellation = loading %v, active %v; want both false", d.Loading(), d.Active())
173				}
174				return
175			}
176			if event.kind == sessionLoadFinished {
177				t.Fatal("canceled load emitted sessionLoadFinished")
178			}
179			d.ApplyLoad(event)
180		case <-time.After(sessionDialogTestTimeout):
181			t.Fatal("timed out waiting for canceled session load to close")
182		}
183	}
184}
185
186func TestSessionDialogPreCanceledOpenPreservesPreviousLoadBarrier(t *testing.T) {
187	root := t.TempDir()
188	cwd := t.TempDir()
189	session, err := core.NewSession(root, cwd, "test", "test-model", "test-version")
190	if err != nil {
191		t.Fatal(err)
192	}
193	if err := session.AppendMessage(provider.Message{
194		Role:    provider.RoleUser,
195		Content: []provider.Content{provider.TextBlock{Text: "wait for the prior load"}},
196	}); err != nil {
197		t.Fatal(err)
198	}
199	if err := session.Close(); err != nil {
200		t.Fatal(err)
201	}
202
203	entered := make(chan struct{})
204	release := make(chan struct{})
205	var enteredOnce sync.Once
206	var releaseOnce sync.Once
207	releaseLoad := func() { releaseOnce.Do(func() { close(release) }) }
208	d := newSessionDialog()
209	d.describeSession = func(ctx context.Context, path string) core.SessionSummary {
210		enteredOnce.Do(func() { close(entered) })
211		<-release
212		return core.SessionSummary{Path: path, MessageCount: 1}
213	}
214	firstCtx, firstCancel := context.WithCancel(context.Background())
215	firstEvents := d.Open(firstCtx, root, cwd)
216	t.Cleanup(func() {
217		firstCancel()
218		releaseLoad()
219		d.Close()
220	})
221
222	select {
223	case event, ok := <-firstEvents:
224		if !ok || event.kind != sessionLoadStarted {
225			t.Fatalf("first load event = %+v, open %v; want sessionLoadStarted", event, ok)
226		}
227		d.ApplyLoad(event)
228	case <-time.After(sessionDialogTestTimeout):
229		t.Fatal("timed out waiting for the first load to start")
230	}
231	select {
232	case <-entered:
233	case <-time.After(sessionDialogTestTimeout):
234		t.Fatal("timed out waiting for the first worker")
235	}
236
237	preCanceled, cancelPreCanceled := context.WithCancel(context.Background())
238	cancelPreCanceled()
239	preCanceledEvents := d.Open(preCanceled, root, cwd)
240	if _, ok := <-preCanceledEvents; ok {
241		t.Fatal("pre-canceled open emitted a load event")
242	}
243
244	thirdCtx, cancelThird := context.WithCancel(context.Background())
245	thirdEvents := d.Open(thirdCtx, root, cwd)
246	select {
247	case event, ok := <-thirdEvents:
248		t.Fatalf("third open started before the first load finished: %+v, open %v", event, ok)
249	case <-time.After(100 * time.Millisecond):
250	}
251
252	releaseLoad()
253	firstCancel()
254	select {
255	case event, ok := <-thirdEvents:
256		if !ok || event.kind != sessionLoadStarted {
257			t.Fatalf("third load event = %+v, open %v; want sessionLoadStarted after barrier", event, ok)
258		}
259		d.ApplyLoad(event)
260	case <-time.After(sessionDialogTestTimeout):
261		t.Fatal("timed out waiting for third load after releasing the first")
262	}
263	cancelThird()
264	for event := range thirdEvents {
265		d.ApplyLoad(event)
266	}
267}
268
269func TestSessionDialogEscapeCancelsLoading(t *testing.T) {
270	d := newSessionDialog()
271	events := d.Open(context.Background(), t.TempDir(), t.TempDir())
272	act := d.HandleKey(tui.Key{Kind: tui.KeyEsc})
273	if !act.Close || d.Active() || d.Loading() {
274		t.Fatalf("escape action = %+v, active %v, loading %v; want closed dialog", act, d.Active(), d.Loading())
275	}
276	for range events {
277	}
278}
279
280func TestSessionDialogAppliesLoadedEntriesIncrementallyInPathOrder(t *testing.T) {
281	d := newSessionDialog()
282	d.active = true
283	d.loading = true
284	d.loadGeneration = 7
285
286	d.ApplyLoad(sessionLoadEvent{
287		kind:       sessionLoadStarted,
288		generation: 7,
289		total:      4,
290	})
291	d.ApplyLoad(sessionLoadEvent{
292		kind:       sessionLoadEntry,
293		generation: 7,
294		index:      2,
295		summary:    core.SessionSummary{Path: "third", MessageCount: 1},
296	})
297	if len(d.sessions) != 0 {
298		t.Fatalf("older out-of-order entry appeared before newest entry: %+v", d.sessions)
299	}
300	d.ApplyLoad(sessionLoadEvent{
301		kind:       sessionLoadEntry,
302		generation: 7,
303		index:      1,
304		summary:    core.SessionSummary{Path: "hidden", MessageCount: 1, HideFromSessions: true},
305	})
306	d.ApplyLoad(sessionLoadEvent{
307		kind:       sessionLoadEntry,
308		generation: 7,
309		index:      3,
310		summary:    core.SessionSummary{Path: "empty"},
311	})
312	d.ApplyLoad(sessionLoadEvent{
313		kind:       sessionLoadEntry,
314		generation: 7,
315		index:      0,
316		summary:    core.SessionSummary{Path: "first", MessageCount: 1},
317	})
318	if !d.Loading() {
319		t.Fatal("dialog stopped loading before the terminal event")
320	}
321	if len(d.sessions) != 2 || d.sessions[0].Path != "first" || d.sessions[1].Path != "third" {
322		t.Fatalf("incremental sessions = %+v, want first then third", d.sessions)
323	}
324
325	d.ApplyLoad(sessionLoadEvent{kind: sessionLoadFinished, generation: 7})
326
327	if d.Loading() {
328		t.Fatal("dialog still loading after final result")
329	}
330	if len(d.sessions) != 2 || d.sessions[0].Path != "first" || d.sessions[1].Path != "third" {
331		t.Fatalf("loaded sessions = %+v, want first then third", d.sessions)
332	}
333}
334
335func TestSessionDialogCanSelectLoadedEntryWhileLoading(t *testing.T) {
336	d := newSessionDialog()
337	d.active = true
338	d.loading = true
339	d.loadGeneration = 9
340
341	d.ApplyLoad(sessionLoadEvent{
342		kind:       sessionLoadStarted,
343		generation: 9,
344		total:      2,
345	})
346	d.ApplyLoad(sessionLoadEvent{
347		kind:       sessionLoadEntry,
348		generation: 9,
349		index:      0,
350		summary:    core.SessionSummary{Path: "newest", MessageCount: 1, FirstUserText: "recent work"},
351	})
352
353	if !d.Loading() {
354		t.Fatal("dialog stopped loading after first entry")
355	}
356	rendered := strings.Join(d.Render(tui.Dark, 100), "\n")
357	if !strings.Contains(rendered, "recent work") {
358		t.Fatalf("incremental render missing newest entry: %q", rendered)
359	}
360
361	act := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
362	if !act.Select || act.Path != "newest" || d.Active() {
363		t.Fatalf("select while loading = %+v, active %v; want newest selected and dialog closed", act, d.Active())
364	}
365}
366
367func TestSessionDialogAllScopeIncludesOtherCWDBuckets(t *testing.T) {
368	root := t.TempDir()
369	cwdA := t.TempDir()
370	cwdB := t.TempDir()
371	for _, cwd := range []string{cwdA, cwdB} {
372		session, err := core.NewSession(root, cwd, "test", "model", "test")
373		if err != nil {
374			t.Fatal(err)
375		}
376		if err := session.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "scope"}}}); err != nil {
377			t.Fatal(err)
378		}
379		if err := session.Close(); err != nil {
380			t.Fatal(err)
381		}
382	}
383	d := newSessionDialog()
384	events := d.Open(context.Background(), root, cwdA, true)
385	defer d.Close()
386	for event := range events {
387		d.ApplyLoad(event)
388	}
389	if !d.allScope || len(d.sessions) != 2 {
390		t.Fatalf("all scope = %v, sessions = %#v", d.allScope, d.sessions)
391	}
392	if action := d.HandleKey(tui.Key{Kind: tui.KeyTab}); !action.ToggleScope {
393		t.Fatalf("tab action = %#v, want scope toggle", action)
394	}
395}
396
397func TestSessionDialogSearchShowsCountsAndExcerpt(t *testing.T) {
398	d := newSessionDialog()
399	d.active = true
400	d.baseSessions = []core.SessionSummary{{Path: "one", MessageCount: 1}, {Path: "two", MessageCount: 1}}
401	d.sessions = append([]core.SessionSummary(nil), d.baseSessions...)
402	d.query = "alpha"
403	d.searchReady = true
404	d.searchGeneration = 1
405	d.searchMatches = matchSessionSearchSegments(context.Background(), d.query, []core.SessionSearchSegment{
406		{Path: "one", Text: "alpha implementation details"},
407		{Path: "one", Text: "alpha follow-up"},
408		{Path: "two", Text: "unrelated text"},
409	})
410	d.applySearchFilter()
411	if len(d.sessions) != 1 || d.sessions[0].Path != "one" {
412		t.Fatalf("search sessions = %#v", d.sessions)
413	}
414	match := d.searchMatches["one"]
415	if match.count != 2 || match.excerpt == "" {
416		t.Fatalf("search match = %#v", match)
417	}
418	rendered := stripANSIBytes(strings.Join(d.Render(tui.Dark, 120), "\n"))
419	if !strings.Contains(rendered, "2 matches") || !strings.Contains(rendered, "alpha") {
420		t.Fatalf("search render = %q", rendered)
421	}
422}
423
424func TestSessionDialogSearchShowsAnimatedLoadingFeedback(t *testing.T) {
425	d := newSessionDialog()
426	d.active = true
427	d.loading = true // Keep StartSearch pending; no background read is needed.
428	d.query = "go"
429
430	if events := d.StartSearch(context.Background()); events == nil {
431		t.Fatal("StartSearch returned nil")
432	}
433	defer d.Close()
434	if !d.SearchLoading() {
435		t.Fatal("search is not reported as loading while indexing")
436	}
437	if d.searchSpinner == nil {
438		t.Fatal("search did not initialize the shared spinner")
439	}
440	indexed := stripANSIBytes(strings.Join(d.Render(tui.Dark, 100), "\n"))
441	if !strings.Contains(indexed, "Indexing session text") {
442		t.Fatalf("indexing render = %q", indexed)
443	}
444
445	d.searching = false
446	d.matching = true
447	matching := stripANSIBytes(strings.Join(d.Render(tui.Dark, 100), "\n"))
448	if !strings.Contains(matching, "Searching session text") {
449		t.Fatalf("matching render = %q", matching)
450	}
451
452	d.matching = false
453	if d.SearchLoading() {
454		t.Fatal("search remains reported as loading after matching completes")
455	}
456}
457
458func TestSessionDialogSearchIgnoresStaleMatchGeneration(t *testing.T) {
459	d := newSessionDialog()
460	d.active = true
461	d.query = "abc"
462	d.searchGeneration = 1
463	d.matchGeneration = 2
464	d.matching = true
465
466	d.ApplySearch(sessionSearchEvent{
467		kind:            sessionSearchMatchesReady,
468		generation:      1,
469		matchGeneration: 1,
470		query:           "abc",
471		matches:         map[string]sessionSearchMatch{"stale": {count: 1}},
472	})
473	if !d.matching {
474		t.Fatal("stale match result cleared the active search state")
475	}
476	if d.searchMatches != nil {
477		t.Fatalf("stale match result was applied: %#v", d.searchMatches)
478	}
479
480	d.ApplySearch(sessionSearchEvent{
481		kind:            sessionSearchMatchesReady,
482		generation:      1,
483		matchGeneration: 2,
484		query:           "abc",
485		matches:         map[string]sessionSearchMatch{"current": {count: 1}},
486	})
487	if d.matching {
488		t.Fatal("current match result did not finish the active search")
489	}
490	if _, ok := d.searchMatches["current"]; !ok {
491		t.Fatalf("current match result was not applied: %#v", d.searchMatches)
492	}
493}
494
495func TestSessionSearchUsesContiguousQueryAndRequiresTwoCharacters(t *testing.T) {
496	segments := []core.SessionSearchSegment{
497		{Path: "scattered", Text: "sizzle then harp, again remote"},
498		{Path: "exact", Text: "resume the sharm project"},
499	}
500
501	if matches := matchSessionSearchSegments(context.Background(), "s", segments); matches != nil {
502		t.Fatalf("one-character search = %#v, want no search", matches)
503	}
504	matches := matchSessionSearchSegments(context.Background(), "sharm", segments)
505	if len(matches) != 1 {
506		t.Fatalf("matches = %#v, want only exact session", matches)
507	}
508	match, ok := matches["exact"]
509	if !ok {
510		t.Fatalf("matches = %#v, missing exact session", matches)
511	}
512	if match.count != 1 || match.excerpt != "resume the sharm project" {
513		t.Fatalf("exact match = %#v", match)
514	}
515	if want := []int{11, 12, 13, 14, 15}; len(match.indexes) != len(want) || match.indexes[0] != want[0] || match.indexes[1] != want[1] || match.indexes[2] != want[2] || match.indexes[3] != want[3] || match.indexes[4] != want[4] {
516		t.Fatalf("highlight indexes = %v, want %v", match.indexes, want)
517	}
518}
519
520func TestSessionSearchCountsRepeatedSegmentMatches(t *testing.T) {
521	matches := matchSessionSearchSegments(context.Background(), "sharm", []core.SessionSearchSegment{
522		{Path: "repeated", Text: "sharm then sharm"},
523	})
524	match, ok := matches["repeated"]
525	if !ok {
526		t.Fatalf("matches = %#v, missing repeated session", matches)
527	}
528	if match.count != 2 {
529		t.Fatalf("match count = %d, want 2", match.count)
530	}
531	if match.excerpt != "sharm then sharm" {
532		t.Fatalf("match excerpt = %q", match.excerpt)
533	}
534	if want := []int{0, 1, 2, 3, 4}; len(match.indexes) != len(want) || match.indexes[0] != want[0] || match.indexes[1] != want[1] || match.indexes[2] != want[2] || match.indexes[3] != want[3] || match.indexes[4] != want[4] {
535		t.Fatalf("highlight indexes = %v, want %v", match.indexes, want)
536	}
537}
538
539func TestSessionDialogDefersSearchUntilSecondCharacter(t *testing.T) {
540	d := newSessionDialog()
541	d.active = true
542	d.baseSessions = []core.SessionSummary{{Path: "one"}, {Path: "two"}}
543	d.sessions = append([]core.SessionSummary(nil), d.baseSessions...)
544
545	if action := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '/'}); action.StartSearch || !d.searchRequested {
546		t.Fatalf("slash action = %#v, search requested = %v", action, d.searchRequested)
547	}
548	if action := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 's'}); action.StartSearch || d.query != "s" {
549		t.Fatalf("one-character action = %#v, query = %q", action, d.query)
550	}
551	if len(d.sessions) != len(d.baseSessions) {
552		t.Fatalf("one-character search filtered sessions: %#v", d.sessions)
553	}
554	if events := d.StartSearch(context.Background()); events != nil || d.searchEvents != nil {
555		t.Fatal("one-character search started the corpus reader")
556	}
557	if action := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'h'}); !action.StartSearch || d.query != "sh" {
558		t.Fatalf("two-character action = %#v, query = %q", action, d.query)
559	}
560}
561
562func TestSessionDialogClearsMatchesBelowSearchMinimum(t *testing.T) {
563	d := newSessionDialog()
564	d.active = true
565	d.searchRequested = true
566	d.searchReady = true
567	d.query = "sh"
568	d.baseSessions = []core.SessionSummary{{Path: "one", MessageCount: 1, FirstUserText: "session"}}
569	d.sessions = append([]core.SessionSummary(nil), d.baseSessions...)
570	d.searchMatches = map[string]sessionSearchMatch{"one": {count: 1}}
571
572	d.HandleKey(tui.Key{Kind: tui.KeyBackspace})
573	if d.searchMatches != nil {
574		t.Fatalf("matches after shortening query = %#v, want nil", d.searchMatches)
575	}
576	rendered := stripANSIBytes(strings.Join(d.Render(tui.Dark, 120), "\n"))
577	if strings.Contains(rendered, "1 match") {
578		t.Fatalf("one-character search rendered stale match count: %q", rendered)
579	}
580}
581
582func TestSessionDialogIgnoresStaleLoadResults(t *testing.T) {
583	first := newSessionDialog()
584	firstEvents := first.Open(context.Background(), t.TempDir(), t.TempDir())
585	firstGeneration := first.loadGeneration
586
587	secondEvents := first.Open(context.Background(), t.TempDir(), t.TempDir())
588	secondGeneration := first.loadGeneration
589	if secondGeneration == firstGeneration {
590		t.Fatal("reopening picker did not advance load generation")
591	}
592	first.ApplyLoad(sessionLoadEvent{
593		kind:       sessionLoadStarted,
594		generation: secondGeneration,
595		total:      1,
596	})
597
598	first.ApplyLoad(sessionLoadEvent{
599		kind:       sessionLoadEntry,
600		generation: firstGeneration,
601		index:      0,
602		summary: core.SessionSummary{
603			Path:         "stale",
604			MessageCount: 1,
605		},
606	})
607	if len(first.sessions) != 0 {
608		t.Fatalf("stale results repopulated picker: %+v", first.sessions)
609	}
610
611	first.Close()
612	for range firstEvents {
613	}
614	for range secondEvents {
615	}
616}
617
618func TestSessionDialogDeleteRequiresConfirmation(t *testing.T) {
619	d := newSessionDialog()
620	d.active = true
621	d.sessions = []core.SessionSummary{{Path: "/sessions/one.jsonl"}}
622
623	if act := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'd'}); act.Delete {
624		t.Fatal("delete hotkey immediately returned a delete action")
625	}
626	if !d.deleting {
627		t.Fatal("delete hotkey did not open confirmation")
628	}
629	if act := d.HandleKey(tui.Key{Kind: tui.KeyEnter}); act.Delete {
630		t.Fatal("enter confirmed deletion; want safe default cancellation")
631	}
632	if d.deleting {
633		t.Fatal("enter did not close deletion confirmation")
634	}
635	if !d.Active() {
636		t.Fatal("enter closed the picker; want the dialog to stay open")
637	}
638
639	d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'd'})
640	if act := d.HandleKey(tui.Key{Kind: tui.KeyEsc}); act.Delete || act.Close {
641		t.Fatalf("esc action = %+v, want silent cancellation", act)
642	}
643	if d.deleting {
644		t.Fatal("esc did not close deletion confirmation")
645	}
646	if !d.Active() {
647		t.Fatal("esc closed the picker; want the dialog to stay open")
648	}
649
650	d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'd'})
651	act := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'y'})
652	if !act.Delete || act.Path != "/sessions/one.jsonl" {
653		t.Fatalf("confirmed action = %+v, want delete for selected path", act)
654	}
655	if len(d.sessions) != 1 {
656		t.Fatal("dialog removed row before the host confirmed filesystem deletion")
657	}
658}
659
660func TestSessionDialogDeleteConfirmationRender(t *testing.T) {
661	d := newSessionDialog()
662	d.active = true
663	d.sessions = []core.SessionSummary{{Path: "/sessions/one.jsonl", Title: "keep or delete"}}
664	d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'd'})
665
666	plain := strings.Join(d.Render(tui.Theme{}, 100), "\n")
667	for _, want := range []string{"permanently delete", "keep or delete", "press y to delete"} {
668		if !strings.Contains(plain, want) {
669			t.Fatalf("confirmation render missing %q:\n%s", want, plain)
670		}
671	}
672	normal := &sessionDialog{active: true, sessions: []core.SessionSummary{{Path: "/sessions/one.jsonl"}}}
673	if got := strings.Join(normal.Render(tui.Theme{}, 100), "\n"); !strings.Contains(got, "d delete") {
674		t.Fatalf("picker hint missing delete key:\n%s", got)
675	}
676}
677
678func TestSessionDialogRemoveKeepsCursorValid(t *testing.T) {
679	tests := []struct {
680		name       string
681		cursor     int
682		removePath string
683		wantCursor int
684		wantPath   string
685	}{
686		{name: "first", cursor: 0, removePath: "a", wantCursor: 0, wantPath: "b"},
687		{name: "middle", cursor: 1, removePath: "b", wantCursor: 1, wantPath: "c"},
688		{name: "last", cursor: 2, removePath: "c", wantCursor: 1, wantPath: "b"},
689	}
690	for _, tt := range tests {
691		t.Run(tt.name, func(t *testing.T) {
692			d := &sessionDialog{
693				sessions:     []core.SessionSummary{{Path: "a"}, {Path: "b"}, {Path: "c"}},
694				baseSessions: []core.SessionSummary{{Path: "a"}, {Path: "b"}, {Path: "c"}},
695				cursor:       tt.cursor,
696			}
697			d.Remove(tt.removePath)
698			if d.cursor != tt.wantCursor {
699				t.Fatalf("cursor = %d, want %d", d.cursor, tt.wantCursor)
700			}
701			if got := d.sessions[d.cursor].Path; got != tt.wantPath {
702				t.Fatalf("selected path = %q, want %q", got, tt.wantPath)
703			}
704			for _, summary := range d.baseSessions {
705				if summary.Path == tt.removePath {
706					t.Fatalf("removed path %q still in base sessions", tt.removePath)
707				}
708			}
709		})
710	}
711
712	d := &sessionDialog{sessions: []core.SessionSummary{{Path: "only"}}}
713	d.Remove("only")
714	if len(d.sessions) != 0 || d.cursor != 0 {
715		t.Fatalf("empty dialog has len %d and cursor %d, want 0 and 0", len(d.sessions), d.cursor)
716	}
717}
718
719func TestSessionDialogRemoveSurvivesLoadRebuild(t *testing.T) {
720	d := &sessionDialog{
721		active:       true,
722		sessions:     []core.SessionSummary{{Path: "b"}},
723		baseSessions: []core.SessionSummary{{Path: "a"}, {Path: "b"}},
724		cursor:       0,
725		loadSlots: []sessionLoadSlot{
726			{loaded: true, summary: core.SessionSummary{Path: "a", MessageCount: 1}},
727			{loaded: true, summary: core.SessionSummary{Path: "b", MessageCount: 1}},
728		},
729	}
730	d.Remove("a")
731	d.rebuildLoadedSessions(2)
732	for _, summary := range d.sessions {
733		if summary.Path == "a" {
734			t.Fatalf("deleted path resurrected by load rebuild: %+v", d.sessions)
735		}
736	}
737	d.applySearchFilter()
738	for _, summary := range d.sessions {
739		if summary.Path == "a" {
740			t.Fatalf("deleted path resurrected by search filter: %+v", d.sessions)
741		}
742	}
743}
744
745func TestApplySessionDeletionRemovesFileAndRow(t *testing.T) {
746	path := filepath.Join(t.TempDir(), "session.jsonl")
747	if err := os.WriteFile(path, []byte("session"), 0o600); err != nil {
748		t.Fatal(err)
749	}
750	i := &Interactive{
751		cfg:           InteractiveConfig{CurrentSessionPath: func() string { return "" }},
752		sessionDialog: &sessionDialog{active: true, sessions: []core.SessionSummary{{Path: path}}},
753	}
754
755	i.applySessionDeletion(path)
756
757	if _, err := os.Stat(path); !os.IsNotExist(err) {
758		t.Fatalf("deleted session still exists: %v", err)
759	}
760	if len(i.sessionDialog.sessions) != 0 {
761		t.Fatal("deleted session remains in picker")
762	}
763	if !strings.Contains(i.statusOK, "deleted session") || i.statusErr != "" {
764		t.Fatalf("statusOK = %q, statusErr = %q", i.statusOK, i.statusErr)
765	}
766}
767
768func TestApplySessionDeletionProtectsActiveSession(t *testing.T) {
769	path := filepath.Join(t.TempDir(), "session.jsonl")
770	if err := os.WriteFile(path, []byte("session"), 0o600); err != nil {
771		t.Fatal(err)
772	}
773	i := &Interactive{
774		cfg:           InteractiveConfig{CurrentSessionPath: func() string { return path }},
775		sessionDialog: &sessionDialog{active: true, sessions: []core.SessionSummary{{Path: path}}},
776	}
777
778	i.applySessionDeletion(path)
779
780	if _, err := os.Stat(path); err != nil {
781		t.Fatalf("active session was deleted: %v", err)
782	}
783	if len(i.sessionDialog.sessions) != 1 {
784		t.Fatal("active session was removed from picker")
785	}
786	if !strings.Contains(i.statusErr, "active session") {
787		t.Fatalf("statusErr = %q, want active-session explanation", i.statusErr)
788	}
789}
790
791func TestApplySessionDeletionRequiresCurrentSessionWiring(t *testing.T) {
792	path := filepath.Join(t.TempDir(), "session.jsonl")
793	if err := os.WriteFile(path, []byte("session"), 0o600); err != nil {
794		t.Fatal(err)
795	}
796	i := &Interactive{
797		sessionDialog: &sessionDialog{active: true, sessions: []core.SessionSummary{{Path: path}}},
798	}
799
800	i.applySessionDeletion(path)
801
802	if _, err := os.Stat(path); err != nil {
803		t.Fatalf("session was deleted without active-session wiring: %v", err)
804	}
805	if !strings.Contains(i.statusErr, "not wired") {
806		t.Fatalf("statusErr = %q, want wiring error", i.statusErr)
807	}
808}
809
810func TestApplySessionDeletionPreservesRowOnError(t *testing.T) {
811	path := filepath.Join(t.TempDir(), "missing.jsonl")
812	i := &Interactive{
813		cfg:           InteractiveConfig{CurrentSessionPath: func() string { return "" }},
814		sessionDialog: &sessionDialog{active: true, sessions: []core.SessionSummary{{Path: path}}},
815	}
816
817	i.applySessionDeletion(path)
818
819	if len(i.sessionDialog.sessions) != 1 {
820		t.Fatal("failed deletion removed session from picker")
821	}
822	if !strings.Contains(i.statusErr, "delete session") {
823		t.Fatalf("statusErr = %q, want deletion error", i.statusErr)
824	}
825}

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 476

↑↓ moveenter openesc close