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}