feat(sessions): show search loading feedback
- commit
- 2295403a945c9ddc701a6d71d816b9a2379fe360
- author
- brice · · 2026-08-20 14:09 UTC
- parent
- fae6c20
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +122 -11
M packages/agent/modes/interactive.go +2 -1
| @@ -1423,8 +1423,9 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 1423 | 1423 | i.mu.Lock() | |
| 1424 | 1424 | busy := i.busy | |
| 1425 | 1425 | sessionLoading := i.sessionDialog.Loading() | |
| 1426 | + | sessionSearching := i.sessionDialog.SearchLoading() | |
| 1426 | 1427 | i.mu.Unlock() | |
| 1427 | - | if busy || sessionLoading || i.btwDialog.Loading() || i.residentAnimating.Load() { | |
| 1428 | + | if busy || sessionLoading || sessionSearching || i.btwDialog.Loading() || i.residentAnimating.Load() { | |
| 1428 | 1429 | requestRedraw() | |
| 1429 | 1430 | } | |
| 1430 | 1431 | } | |
| @@ -1423,8 +1423,9 @@ func (i *Interactive) Run(ctx context.Context) error { | |||||
| 1423 | i.mu.Lock() | 1423 | i.mu.Lock() | ||
| 1424 | busy := i.busy | 1424 | busy := i.busy | ||
| 1425 | sessionLoading := i.sessionDialog.Loading() | 1425 | sessionLoading := i.sessionDialog.Loading() | ||
| 1426 | + | sessionSearching := i.sessionDialog.SearchLoading() | |||
| 1426 | i.mu.Unlock() | 1427 | i.mu.Unlock() | ||
| 1427 | - | if busy || sessionLoading || i.btwDialog.Loading() || i.residentAnimating.Load() { | 1428 | + | if busy || sessionLoading || sessionSearching || i.btwDialog.Loading() || i.residentAnimating.Load() { |
| 1428 | requestRedraw() | 1429 | requestRedraw() | ||
| 1429 | } | 1430 | } | ||
| 1430 | } | 1431 | } | ||
M packages/agent/modes/session_dialog.go +49 -10
| @@ -28,12 +28,13 @@ const ( | |||
| 28 | 28 | ) | |
| 29 | 29 | ||
| 30 | 30 | type sessionSearchEvent struct { | |
| 31 | - | kind sessionSearchEventKind | |
| 32 | - | generation uint64 | |
| 33 | - | query string | |
| 34 | - | segments []core.SessionSearchSegment | |
| 35 | - | matches map[string]sessionSearchMatch | |
| 36 | - | skipped int | |
| 31 | + | kind sessionSearchEventKind | |
| 32 | + | generation uint64 | |
| 33 | + | matchGeneration uint64 | |
| 34 | + | query string | |
| 35 | + | segments []core.SessionSearchSegment | |
| 36 | + | matches map[string]sessionSearchMatch | |
| 37 | + | skipped int | |
| 37 | 38 | } | |
| 38 | 39 | ||
| 39 | 40 | type sessionSearchMatch struct { | |
| @@ -82,9 +83,12 @@ type sessionDialog struct { | |||
| 82 | 83 | query string | |
| 83 | 84 | searchRequested bool | |
| 84 | 85 | searching bool | |
| 86 | + | matching bool | |
| 85 | 87 | searchPending bool | |
| 86 | 88 | searchReady bool | |
| 89 | + | searchSpinner *spinner | |
| 87 | 90 | searchGeneration uint64 | |
| 91 | + | matchGeneration uint64 | |
| 88 | 92 | searchContext context.Context | |
| 89 | 93 | searchCancel context.CancelFunc | |
| 90 | 94 | matchCancel context.CancelFunc | |
| @@ -177,8 +181,11 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string, allScope | |||
| 177 | 181 | d.query = "" | |
| 178 | 182 | d.searchRequested = false | |
| 179 | 183 | d.searching = false | |
| 184 | + | d.matching = false | |
| 180 | 185 | d.searchPending = false | |
| 186 | + | d.searchSpinner = nil | |
| 181 | 187 | d.searchReady = false | |
| 188 | + | d.matchGeneration = 0 | |
| 182 | 189 | d.searchSegments = nil | |
| 183 | 190 | d.searchMatches = nil | |
| 184 | 191 | d.searchErr = "" | |
| @@ -433,6 +440,8 @@ func (d *sessionDialog) Close() { | |||
| 433 | 440 | d.loading = false | |
| 434 | 441 | d.searchRequested = false | |
| 435 | 442 | d.searching = false | |
| 443 | + | d.matching = false | |
| 444 | + | d.searchSpinner = nil | |
| 436 | 445 | d.loadSlots = nil | |
| 437 | 446 | d.loadReadyThrough = 0 | |
| 438 | 447 | d.searchEvents = nil | |
| @@ -458,6 +467,8 @@ func (d *sessionDialog) StartSearch(parent context.Context) <-chan sessionSearch | |||
| 458 | 467 | d.searchContext = ctx | |
| 459 | 468 | d.searchCancel = cancel | |
| 460 | 469 | d.searching = true | |
| 470 | + | d.matching = false | |
| 471 | + | d.searchSpinner = &spinner{startedAt: time.Now()} | |
| 461 | 472 | d.searchGeneration++ | |
| 462 | 473 | events := make(chan sessionSearchEvent, 1) | |
| 463 | 474 | d.searchEvents = events | |
| @@ -516,9 +527,10 @@ func (d *sessionDialog) ApplySearch(event sessionSearchEvent) { | |||
| 516 | 527 | d.searchSegments = event.segments | |
| 517 | 528 | d.scheduleSearchMatch() | |
| 518 | 529 | case sessionSearchMatchesReady: | |
| 519 | - | if event.query != d.query { | |
| 530 | + | if event.query != d.query || event.matchGeneration != d.matchGeneration { | |
| 520 | 531 | return | |
| 521 | 532 | } | |
| 533 | + | d.matching = false | |
| 522 | 534 | d.searchMatches = event.matches | |
| 523 | 535 | d.applySearchFilter() | |
| 524 | 536 | } | |
| @@ -531,9 +543,12 @@ func (d *sessionDialog) scheduleSearchMatch() { | |||
| 531 | 543 | if d.matchCancel != nil { | |
| 532 | 544 | d.matchCancel() | |
| 533 | 545 | } | |
| 546 | + | d.matching = true | |
| 547 | + | d.matchGeneration++ | |
| 534 | 548 | query := d.query | |
| 535 | 549 | segments := d.searchSegments | |
| 536 | 550 | generation := d.searchGeneration | |
| 551 | + | matchGeneration := d.matchGeneration | |
| 537 | 552 | events := d.searchEvents | |
| 538 | 553 | ctx, cancel := context.WithCancel(d.searchContext) | |
| 539 | 554 | d.matchCancel = cancel | |
| @@ -543,7 +558,7 @@ func (d *sessionDialog) scheduleSearchMatch() { | |||
| 543 | 558 | return | |
| 544 | 559 | } | |
| 545 | 560 | select { | |
| 546 | - | case events <- sessionSearchEvent{kind: sessionSearchMatchesReady, generation: generation, query: query, matches: matches}: | |
| 561 | + | case events <- sessionSearchEvent{kind: sessionSearchMatchesReady, generation: generation, matchGeneration: matchGeneration, query: query, matches: matches}: | |
| 547 | 562 | case <-ctx.Done(): | |
| 548 | 563 | } | |
| 549 | 564 | }() | |
| @@ -628,6 +643,11 @@ func (d *sessionDialog) Active() bool { return d != nil && d.active } | |||
| 628 | 643 | // Loading reports whether the dialog still has session entries in flight. | |
| 629 | 644 | func (d *sessionDialog) Loading() bool { return d != nil && d.active && d.loading } | |
| 630 | 645 | ||
| 646 | + | // SearchLoading reports whether the dialog is reading or matching session text. | |
| 647 | + | func (d *sessionDialog) SearchLoading() bool { | |
| 648 | + | return d != nil && d.active && (d.searching || d.matching) | |
| 649 | + | } | |
| 650 | + | ||
| 631 | 651 | // Render returns the dialog lines. | |
| 632 | 652 | func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |
| 633 | 653 | if !d.Active() { | |
| @@ -639,6 +659,9 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |||
| 639 | 659 | title = "sessions · all" | |
| 640 | 660 | } | |
| 641 | 661 | lines = append(lines, frameHeader(th, title, width)) | |
| 662 | + | if d.SearchLoading() { | |
| 663 | + | lines = append(lines, d.searchLoadingMessage(th)) | |
| 664 | + | } | |
| 642 | 665 | if d.loading { | |
| 643 | 666 | lines = append(lines, th.FGColor(th.Muted, d.loadingMessage(th))) | |
| 644 | 667 | if len(d.sessions) == 0 { | |
| @@ -676,8 +699,6 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |||
| 676 | 699 | hint = "search: " + d.query + " (esc clear · tab scope)" | |
| 677 | 700 | if !sessionSearchQuery(d.query) { | |
| 678 | 701 | hint += " · type 2+ characters" | |
| 679 | - | } else if d.searching { | |
| 680 | - | hint += " · indexing" | |
| 681 | 702 | } | |
| 682 | 703 | } | |
| 683 | 704 | if d.loading { | |
| @@ -901,6 +922,18 @@ func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string { | |||
| 901 | 922 | return row | |
| 902 | 923 | } | |
| 903 | 924 | ||
| 925 | + | func (d *sessionDialog) searchLoadingMessage(th tui.Theme) string { | |
| 926 | + | if d.searchSpinner == nil { | |
| 927 | + | d.searchSpinner = &spinner{startedAt: time.Now()} | |
| 928 | + | } | |
| 929 | + | d.searchSpinner.Configure(th) | |
| 930 | + | message := "Searching session text" | |
| 931 | + | if d.searching { | |
| 932 | + | message = "Indexing session text" | |
| 933 | + | } | |
| 934 | + | return th.FGColor(th.Spinner, d.searchSpinner.Frame()) + " " + th.FGColor(th.Muted, message+"…") | |
| 935 | + | } | |
| 936 | + | ||
| 904 | 937 | func (d *sessionDialog) loadingMessage(th tui.Theme) string { | |
| 905 | 938 | frames := th.SpinnerFrames | |
| 906 | 939 | if len(frames) == 0 { | |
| @@ -1009,6 +1042,11 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { | |||
| 1009 | 1042 | return sessionDialogAction{Close: true} | |
| 1010 | 1043 | } | |
| 1011 | 1044 | d.query = "" | |
| 1045 | + | if d.matchCancel != nil { | |
| 1046 | + | d.matchCancel() | |
| 1047 | + | d.matchCancel = nil | |
| 1048 | + | } | |
| 1049 | + | d.matching = false | |
| 1012 | 1050 | d.searchMatches = nil | |
| 1013 | 1051 | d.applySearchFilter() | |
| 1014 | 1052 | return sessionDialogAction{} | |
| @@ -1022,6 +1060,7 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { | |||
| 1022 | 1060 | d.matchCancel() | |
| 1023 | 1061 | d.matchCancel = nil | |
| 1024 | 1062 | } | |
| 1063 | + | d.matching = false | |
| 1025 | 1064 | d.searchMatches = nil | |
| 1026 | 1065 | } | |
| 1027 | 1066 | d.applySearchFilter() | |
| @@ -28,12 +28,13 @@ const ( | |||||
| 28 | ) | 28 | ) | ||
| 29 | 29 | ||||
| 30 | type sessionSearchEvent struct { | 30 | type sessionSearchEvent struct { | ||
| 31 | - | kind sessionSearchEventKind | 31 | + | kind sessionSearchEventKind |
| 32 | - | generation uint64 | 32 | + | generation uint64 |
| 33 | - | query string | 33 | + | matchGeneration uint64 |
| 34 | - | segments []core.SessionSearchSegment | 34 | + | query string |
| 35 | - | matches map[string]sessionSearchMatch | 35 | + | segments []core.SessionSearchSegment |
| 36 | - | skipped int | 36 | + | matches map[string]sessionSearchMatch |
| 37 | + | skipped int | |||
| 37 | } | 38 | } | ||
| 38 | 39 | ||||
| 39 | type sessionSearchMatch struct { | 40 | type sessionSearchMatch struct { | ||
| @@ -82,9 +83,12 @@ type sessionDialog struct { | |||||
| 82 | query string | 83 | query string | ||
| 83 | searchRequested bool | 84 | searchRequested bool | ||
| 84 | searching bool | 85 | searching bool | ||
| 86 | + | matching bool | |||
| 85 | searchPending bool | 87 | searchPending bool | ||
| 86 | searchReady bool | 88 | searchReady bool | ||
| 89 | + | searchSpinner *spinner | |||
| 87 | searchGeneration uint64 | 90 | searchGeneration uint64 | ||
| 91 | + | matchGeneration uint64 | |||
| 88 | searchContext context.Context | 92 | searchContext context.Context | ||
| 89 | searchCancel context.CancelFunc | 93 | searchCancel context.CancelFunc | ||
| 90 | matchCancel context.CancelFunc | 94 | matchCancel context.CancelFunc | ||
| @@ -177,8 +181,11 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string, allScope | |||||
| 177 | d.query = "" | 181 | d.query = "" | ||
| 178 | d.searchRequested = false | 182 | d.searchRequested = false | ||
| 179 | d.searching = false | 183 | d.searching = false | ||
| 184 | + | d.matching = false | |||
| 180 | d.searchPending = false | 185 | d.searchPending = false | ||
| 186 | + | d.searchSpinner = nil | |||
| 181 | d.searchReady = false | 187 | d.searchReady = false | ||
| 188 | + | d.matchGeneration = 0 | |||
| 182 | d.searchSegments = nil | 189 | d.searchSegments = nil | ||
| 183 | d.searchMatches = nil | 190 | d.searchMatches = nil | ||
| 184 | d.searchErr = "" | 191 | d.searchErr = "" | ||
| @@ -433,6 +440,8 @@ func (d *sessionDialog) Close() { | |||||
| 433 | d.loading = false | 440 | d.loading = false | ||
| 434 | d.searchRequested = false | 441 | d.searchRequested = false | ||
| 435 | d.searching = false | 442 | d.searching = false | ||
| 443 | + | d.matching = false | |||
| 444 | + | d.searchSpinner = nil | |||
| 436 | d.loadSlots = nil | 445 | d.loadSlots = nil | ||
| 437 | d.loadReadyThrough = 0 | 446 | d.loadReadyThrough = 0 | ||
| 438 | d.searchEvents = nil | 447 | d.searchEvents = nil | ||
| @@ -458,6 +467,8 @@ func (d *sessionDialog) StartSearch(parent context.Context) <-chan sessionSearch | |||||
| 458 | d.searchContext = ctx | 467 | d.searchContext = ctx | ||
| 459 | d.searchCancel = cancel | 468 | d.searchCancel = cancel | ||
| 460 | d.searching = true | 469 | d.searching = true | ||
| 470 | + | d.matching = false | |||
| 471 | + | d.searchSpinner = &spinner{startedAt: time.Now()} | |||
| 461 | d.searchGeneration++ | 472 | d.searchGeneration++ | ||
| 462 | events := make(chan sessionSearchEvent, 1) | 473 | events := make(chan sessionSearchEvent, 1) | ||
| 463 | d.searchEvents = events | 474 | d.searchEvents = events | ||
| @@ -516,9 +527,10 @@ func (d *sessionDialog) ApplySearch(event sessionSearchEvent) { | |||||
| 516 | d.searchSegments = event.segments | 527 | d.searchSegments = event.segments | ||
| 517 | d.scheduleSearchMatch() | 528 | d.scheduleSearchMatch() | ||
| 518 | case sessionSearchMatchesReady: | 529 | case sessionSearchMatchesReady: | ||
| 519 | - | if event.query != d.query { | 530 | + | if event.query != d.query || event.matchGeneration != d.matchGeneration { |
| 520 | return | 531 | return | ||
| 521 | } | 532 | } | ||
| 533 | + | d.matching = false | |||
| 522 | d.searchMatches = event.matches | 534 | d.searchMatches = event.matches | ||
| 523 | d.applySearchFilter() | 535 | d.applySearchFilter() | ||
| 524 | } | 536 | } | ||
| @@ -531,9 +543,12 @@ func (d *sessionDialog) scheduleSearchMatch() { | |||||
| 531 | if d.matchCancel != nil { | 543 | if d.matchCancel != nil { | ||
| 532 | d.matchCancel() | 544 | d.matchCancel() | ||
| 533 | } | 545 | } | ||
| 546 | + | d.matching = true | |||
| 547 | + | d.matchGeneration++ | |||
| 534 | query := d.query | 548 | query := d.query | ||
| 535 | segments := d.searchSegments | 549 | segments := d.searchSegments | ||
| 536 | generation := d.searchGeneration | 550 | generation := d.searchGeneration | ||
| 551 | + | matchGeneration := d.matchGeneration | |||
| 537 | events := d.searchEvents | 552 | events := d.searchEvents | ||
| 538 | ctx, cancel := context.WithCancel(d.searchContext) | 553 | ctx, cancel := context.WithCancel(d.searchContext) | ||
| 539 | d.matchCancel = cancel | 554 | d.matchCancel = cancel | ||
| @@ -543,7 +558,7 @@ func (d *sessionDialog) scheduleSearchMatch() { | |||||
| 543 | return | 558 | return | ||
| 544 | } | 559 | } | ||
| 545 | select { | 560 | select { | ||
| 546 | - | case events <- sessionSearchEvent{kind: sessionSearchMatchesReady, generation: generation, query: query, matches: matches}: | 561 | + | case events <- sessionSearchEvent{kind: sessionSearchMatchesReady, generation: generation, matchGeneration: matchGeneration, query: query, matches: matches}: |
| 547 | case <-ctx.Done(): | 562 | case <-ctx.Done(): | ||
| 548 | } | 563 | } | ||
| 549 | }() | 564 | }() | ||
| @@ -628,6 +643,11 @@ func (d *sessionDialog) Active() bool { return d != nil && d.active } | |||||
| 628 | // Loading reports whether the dialog still has session entries in flight. | 643 | // Loading reports whether the dialog still has session entries in flight. | ||
| 629 | func (d *sessionDialog) Loading() bool { return d != nil && d.active && d.loading } | 644 | func (d *sessionDialog) Loading() bool { return d != nil && d.active && d.loading } | ||
| 630 | 645 | ||||
| 646 | + | // SearchLoading reports whether the dialog is reading or matching session text. | |||
| 647 | + | func (d *sessionDialog) SearchLoading() bool { | |||
| 648 | + | return d != nil && d.active && (d.searching || d.matching) | |||
| 649 | + | } | |||
| 650 | + | ||||
| 631 | // Render returns the dialog lines. | 651 | // Render returns the dialog lines. | ||
| 632 | func (d *sessionDialog) Render(th tui.Theme, width int) []string { | 652 | func (d *sessionDialog) Render(th tui.Theme, width int) []string { | ||
| 633 | if !d.Active() { | 653 | if !d.Active() { | ||
| @@ -639,6 +659,9 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |||||
| 639 | title = "sessions · all" | 659 | title = "sessions · all" | ||
| 640 | } | 660 | } | ||
| 641 | lines = append(lines, frameHeader(th, title, width)) | 661 | lines = append(lines, frameHeader(th, title, width)) | ||
| 662 | + | if d.SearchLoading() { | |||
| 663 | + | lines = append(lines, d.searchLoadingMessage(th)) | |||
| 664 | + | } | |||
| 642 | if d.loading { | 665 | if d.loading { | ||
| 643 | lines = append(lines, th.FGColor(th.Muted, d.loadingMessage(th))) | 666 | lines = append(lines, th.FGColor(th.Muted, d.loadingMessage(th))) | ||
| 644 | if len(d.sessions) == 0 { | 667 | if len(d.sessions) == 0 { | ||
| @@ -676,8 +699,6 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |||||
| 676 | hint = "search: " + d.query + " (esc clear · tab scope)" | 699 | hint = "search: " + d.query + " (esc clear · tab scope)" | ||
| 677 | if !sessionSearchQuery(d.query) { | 700 | if !sessionSearchQuery(d.query) { | ||
| 678 | hint += " · type 2+ characters" | 701 | hint += " · type 2+ characters" | ||
| 679 | - | } else if d.searching { | |||
| 680 | - | hint += " · indexing" | |||
| 681 | } | 702 | } | ||
| 682 | } | 703 | } | ||
| 683 | if d.loading { | 704 | if d.loading { | ||
| @@ -901,6 +922,18 @@ func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string { | |||||
| 901 | return row | 922 | return row | ||
| 902 | } | 923 | } | ||
| 903 | 924 | ||||
| 925 | + | func (d *sessionDialog) searchLoadingMessage(th tui.Theme) string { | |||
| 926 | + | if d.searchSpinner == nil { | |||
| 927 | + | d.searchSpinner = &spinner{startedAt: time.Now()} | |||
| 928 | + | } | |||
| 929 | + | d.searchSpinner.Configure(th) | |||
| 930 | + | message := "Searching session text" | |||
| 931 | + | if d.searching { | |||
| 932 | + | message = "Indexing session text" | |||
| 933 | + | } | |||
| 934 | + | return th.FGColor(th.Spinner, d.searchSpinner.Frame()) + " " + th.FGColor(th.Muted, message+"…") | |||
| 935 | + | } | |||
| 936 | + | ||||
| 904 | func (d *sessionDialog) loadingMessage(th tui.Theme) string { | 937 | func (d *sessionDialog) loadingMessage(th tui.Theme) string { | ||
| 905 | frames := th.SpinnerFrames | 938 | frames := th.SpinnerFrames | ||
| 906 | if len(frames) == 0 { | 939 | if len(frames) == 0 { | ||
| @@ -1009,6 +1042,11 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { | |||||
| 1009 | return sessionDialogAction{Close: true} | 1042 | return sessionDialogAction{Close: true} | ||
| 1010 | } | 1043 | } | ||
| 1011 | d.query = "" | 1044 | d.query = "" | ||
| 1045 | + | if d.matchCancel != nil { | |||
| 1046 | + | d.matchCancel() | |||
| 1047 | + | d.matchCancel = nil | |||
| 1048 | + | } | |||
| 1049 | + | d.matching = false | |||
| 1012 | d.searchMatches = nil | 1050 | d.searchMatches = nil | ||
| 1013 | d.applySearchFilter() | 1051 | d.applySearchFilter() | ||
| 1014 | return sessionDialogAction{} | 1052 | return sessionDialogAction{} | ||
| @@ -1022,6 +1060,7 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { | |||||
| 1022 | d.matchCancel() | 1060 | d.matchCancel() | ||
| 1023 | d.matchCancel = nil | 1061 | d.matchCancel = nil | ||
| 1024 | } | 1062 | } | ||
| 1063 | + | d.matching = false | |||
| 1025 | d.searchMatches = nil | 1064 | d.searchMatches = nil | ||
| 1026 | } | 1065 | } | ||
| 1027 | d.applySearchFilter() | 1066 | d.applySearchFilter() | ||
M packages/agent/modes/session_dialog_test.go +71 -0
| @@ -419,6 +419,77 @@ func TestSessionDialogSearchShowsCountsAndExcerpt(t *testing.T) { | |||
| 419 | 419 | } | |
| 420 | 420 | } | |
| 421 | 421 | ||
| 422 | + | func TestSessionDialogSearchShowsAnimatedLoadingFeedback(t *testing.T) { | |
| 423 | + | d := newSessionDialog() | |
| 424 | + | d.active = true | |
| 425 | + | d.loading = true // Keep StartSearch pending; no background read is needed. | |
| 426 | + | d.query = "go" | |
| 427 | + | ||
| 428 | + | if events := d.StartSearch(context.Background()); events == nil { | |
| 429 | + | t.Fatal("StartSearch returned nil") | |
| 430 | + | } | |
| 431 | + | defer d.Close() | |
| 432 | + | if !d.SearchLoading() { | |
| 433 | + | t.Fatal("search is not reported as loading while indexing") | |
| 434 | + | } | |
| 435 | + | if d.searchSpinner == nil { | |
| 436 | + | t.Fatal("search did not initialize the shared spinner") | |
| 437 | + | } | |
| 438 | + | indexed := stripANSIBytes(strings.Join(d.Render(tui.Dark, 100), "\n")) | |
| 439 | + | if !strings.Contains(indexed, "Indexing session text") { | |
| 440 | + | t.Fatalf("indexing render = %q", indexed) | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | d.searching = false | |
| 444 | + | d.matching = true | |
| 445 | + | matching := stripANSIBytes(strings.Join(d.Render(tui.Dark, 100), "\n")) | |
| 446 | + | if !strings.Contains(matching, "Searching session text") { | |
| 447 | + | t.Fatalf("matching render = %q", matching) | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | d.matching = false | |
| 451 | + | if d.SearchLoading() { | |
| 452 | + | t.Fatal("search remains reported as loading after matching completes") | |
| 453 | + | } | |
| 454 | + | } | |
| 455 | + | ||
| 456 | + | func TestSessionDialogSearchIgnoresStaleMatchGeneration(t *testing.T) { | |
| 457 | + | d := newSessionDialog() | |
| 458 | + | d.active = true | |
| 459 | + | d.query = "abc" | |
| 460 | + | d.searchGeneration = 1 | |
| 461 | + | d.matchGeneration = 2 | |
| 462 | + | d.matching = true | |
| 463 | + | ||
| 464 | + | d.ApplySearch(sessionSearchEvent{ | |
| 465 | + | kind: sessionSearchMatchesReady, | |
| 466 | + | generation: 1, | |
| 467 | + | matchGeneration: 1, | |
| 468 | + | query: "abc", | |
| 469 | + | matches: map[string]sessionSearchMatch{"stale": {count: 1}}, | |
| 470 | + | }) | |
| 471 | + | if !d.matching { | |
| 472 | + | t.Fatal("stale match result cleared the active search state") | |
| 473 | + | } | |
| 474 | + | if d.searchMatches != nil { | |
| 475 | + | t.Fatalf("stale match result was applied: %#v", d.searchMatches) | |
| 476 | + | } | |
| 477 | + | ||
| 478 | + | d.ApplySearch(sessionSearchEvent{ | |
| 479 | + | kind: sessionSearchMatchesReady, | |
| 480 | + | generation: 1, | |
| 481 | + | matchGeneration: 2, | |
| 482 | + | query: "abc", | |
| 483 | + | matches: map[string]sessionSearchMatch{"current": {count: 1}}, | |
| 484 | + | }) | |
| 485 | + | if d.matching { | |
| 486 | + | t.Fatal("current match result did not finish the active search") | |
| 487 | + | } | |
| 488 | + | if _, ok := d.searchMatches["current"]; !ok { | |
| 489 | + | t.Fatalf("current match result was not applied: %#v", d.searchMatches) | |
| 490 | + | } | |
| 491 | + | } | |
| 492 | + | ||
| 422 | 493 | func TestSessionSearchUsesContiguousQueryAndRequiresTwoCharacters(t *testing.T) { | |
| 423 | 494 | segments := []core.SessionSearchSegment{ | |
| 424 | 495 | {Path: "scattered", Text: "sizzle then harp, again remote"}, | |
| @@ -419,6 +419,77 @@ func TestSessionDialogSearchShowsCountsAndExcerpt(t *testing.T) { | |||||
| 419 | } | 419 | } | ||
| 420 | } | 420 | } | ||
| 421 | 421 | ||||
| 422 | + | func TestSessionDialogSearchShowsAnimatedLoadingFeedback(t *testing.T) { | |||
| 423 | + | d := newSessionDialog() | |||
| 424 | + | d.active = true | |||
| 425 | + | d.loading = true // Keep StartSearch pending; no background read is needed. | |||
| 426 | + | d.query = "go" | |||
| 427 | + | ||||
| 428 | + | if events := d.StartSearch(context.Background()); events == nil { | |||
| 429 | + | t.Fatal("StartSearch returned nil") | |||
| 430 | + | } | |||
| 431 | + | defer d.Close() | |||
| 432 | + | if !d.SearchLoading() { | |||
| 433 | + | t.Fatal("search is not reported as loading while indexing") | |||
| 434 | + | } | |||
| 435 | + | if d.searchSpinner == nil { | |||
| 436 | + | t.Fatal("search did not initialize the shared spinner") | |||
| 437 | + | } | |||
| 438 | + | indexed := stripANSIBytes(strings.Join(d.Render(tui.Dark, 100), "\n")) | |||
| 439 | + | if !strings.Contains(indexed, "Indexing session text") { | |||
| 440 | + | t.Fatalf("indexing render = %q", indexed) | |||
| 441 | + | } | |||
| 442 | + | ||||
| 443 | + | d.searching = false | |||
| 444 | + | d.matching = true | |||
| 445 | + | matching := stripANSIBytes(strings.Join(d.Render(tui.Dark, 100), "\n")) | |||
| 446 | + | if !strings.Contains(matching, "Searching session text") { | |||
| 447 | + | t.Fatalf("matching render = %q", matching) | |||
| 448 | + | } | |||
| 449 | + | ||||
| 450 | + | d.matching = false | |||
| 451 | + | if d.SearchLoading() { | |||
| 452 | + | t.Fatal("search remains reported as loading after matching completes") | |||
| 453 | + | } | |||
| 454 | + | } | |||
| 455 | + | ||||
| 456 | + | func TestSessionDialogSearchIgnoresStaleMatchGeneration(t *testing.T) { | |||
| 457 | + | d := newSessionDialog() | |||
| 458 | + | d.active = true | |||
| 459 | + | d.query = "abc" | |||
| 460 | + | d.searchGeneration = 1 | |||
| 461 | + | d.matchGeneration = 2 | |||
| 462 | + | d.matching = true | |||
| 463 | + | ||||
| 464 | + | d.ApplySearch(sessionSearchEvent{ | |||
| 465 | + | kind: sessionSearchMatchesReady, | |||
| 466 | + | generation: 1, | |||
| 467 | + | matchGeneration: 1, | |||
| 468 | + | query: "abc", | |||
| 469 | + | matches: map[string]sessionSearchMatch{"stale": {count: 1}}, | |||
| 470 | + | }) | |||
| 471 | + | if !d.matching { | |||
| 472 | + | t.Fatal("stale match result cleared the active search state") | |||
| 473 | + | } | |||
| 474 | + | if d.searchMatches != nil { | |||
| 475 | + | t.Fatalf("stale match result was applied: %#v", d.searchMatches) | |||
| 476 | + | } | |||
| 477 | + | ||||
| 478 | + | d.ApplySearch(sessionSearchEvent{ | |||
| 479 | + | kind: sessionSearchMatchesReady, | |||
| 480 | + | generation: 1, | |||
| 481 | + | matchGeneration: 2, | |||
| 482 | + | query: "abc", | |||
| 483 | + | matches: map[string]sessionSearchMatch{"current": {count: 1}}, | |||
| 484 | + | }) | |||
| 485 | + | if d.matching { | |||
| 486 | + | t.Fatal("current match result did not finish the active search") | |||
| 487 | + | } | |||
| 488 | + | if _, ok := d.searchMatches["current"]; !ok { | |||
| 489 | + | t.Fatalf("current match result was not applied: %#v", d.searchMatches) | |||
| 490 | + | } | |||
| 491 | + | } | |||
| 492 | + | ||||
| 422 | func TestSessionSearchUsesContiguousQueryAndRequiresTwoCharacters(t *testing.T) { | 493 | func TestSessionSearchUsesContiguousQueryAndRequiresTwoCharacters(t *testing.T) { | ||
| 423 | segments := []core.SessionSearchSegment{ | 494 | segments := []core.SessionSearchSegment{ | ||
| 424 | {Path: "scattered", Text: "sizzle then harp, again remote"}, | 495 | {Path: "scattered", Text: "sizzle then harp, again remote"}, | ||