fix(sessions): harden picker search lifecycle
- commit
- caa47a71af00d7287ae98c62eb54658b5ca6030a
- author
- brice · · 2026-08-19 05:45 UTC
- parent
- 0c5f7c1
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +70 -10
M packages/agent/cli.go +1 -2
| @@ -2045,14 +2045,13 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2045 | 2045 | } | |
| 2046 | 2046 | return fmt.Errorf("selected session cwd is not a directory: %s", selectedMeta.CWD) | |
| 2047 | 2047 | } | |
| 2048 | - | workspaceChanged := filepath.Clean(selectedMeta.CWD) != filepath.Clean(args.CWD) | |
| 2049 | - | ||
| 2050 | 2048 | // Hold the transition lock from the pre-flush through the commit. | |
| 2051 | 2049 | // Persistence callbacks take the read side, so an active session | |
| 2052 | 2050 | // cannot be snapshotted before its lazy writes land or overwritten by | |
| 2053 | 2051 | // a callback while the candidate is being installed. | |
| 2054 | 2052 | sessionTransitionMu.Lock() | |
| 2055 | 2053 | defer sessionTransitionMu.Unlock() | |
| 2054 | + | workspaceChanged := filepath.Clean(selectedMeta.CWD) != filepath.Clean(args.CWD) | |
| 2056 | 2055 | ||
| 2057 | 2056 | currentAg := liveInteractiveAgent(iv, ag) | |
| 2058 | 2057 | if currentAg == nil { | |
| @@ -2045,14 +2045,13 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 2045 | } | 2045 | } | ||
| 2046 | return fmt.Errorf("selected session cwd is not a directory: %s", selectedMeta.CWD) | 2046 | return fmt.Errorf("selected session cwd is not a directory: %s", selectedMeta.CWD) | ||
| 2047 | } | 2047 | } | ||
| 2048 | - | workspaceChanged := filepath.Clean(selectedMeta.CWD) != filepath.Clean(args.CWD) | |||
| 2049 | - | ||||
| 2050 | // Hold the transition lock from the pre-flush through the commit. | 2048 | // Hold the transition lock from the pre-flush through the commit. | ||
| 2051 | // Persistence callbacks take the read side, so an active session | 2049 | // Persistence callbacks take the read side, so an active session | ||
| 2052 | // cannot be snapshotted before its lazy writes land or overwritten by | 2050 | // cannot be snapshotted before its lazy writes land or overwritten by | ||
| 2053 | // a callback while the candidate is being installed. | 2051 | // a callback while the candidate is being installed. | ||
| 2054 | sessionTransitionMu.Lock() | 2052 | sessionTransitionMu.Lock() | ||
| 2055 | defer sessionTransitionMu.Unlock() | 2053 | defer sessionTransitionMu.Unlock() | ||
| 2054 | + | workspaceChanged := filepath.Clean(selectedMeta.CWD) != filepath.Clean(args.CWD) | |||
| 2056 | 2055 | ||||
| 2057 | currentAg := liveInteractiveAgent(iv, ag) | 2056 | currentAg := liveInteractiveAgent(iv, ag) | ||
| 2058 | if currentAg == nil { | 2057 | if currentAg == nil { | ||
M packages/agent/modes/session_dialog.go +59 -7
| @@ -80,9 +80,12 @@ type sessionDialog struct { | |||
| 80 | 80 | paths []string | |
| 81 | 81 | query string | |
| 82 | 82 | searching bool | |
| 83 | + | searchPending bool | |
| 83 | 84 | searchReady bool | |
| 84 | 85 | searchGeneration uint64 | |
| 86 | + | searchContext context.Context | |
| 85 | 87 | searchCancel context.CancelFunc | |
| 88 | + | matchCancel context.CancelFunc | |
| 86 | 89 | searchEvents chan sessionSearchEvent | |
| 87 | 90 | searchSegments []core.SessionSearchSegment | |
| 88 | 91 | searchMatches map[string]sessionSearchMatch | |
| @@ -145,6 +148,10 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string, allScope | |||
| 145 | 148 | d.searchCancel() | |
| 146 | 149 | d.searchCancel = nil | |
| 147 | 150 | } | |
| 151 | + | if d.matchCancel != nil { | |
| 152 | + | d.matchCancel() | |
| 153 | + | d.matchCancel = nil | |
| 154 | + | } | |
| 148 | 155 | previousDone := d.loadDone | |
| 149 | 156 | if parent == nil { | |
| 150 | 157 | parent = context.Background() | |
| @@ -167,6 +174,7 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string, allScope | |||
| 167 | 174 | d.paths = nil | |
| 168 | 175 | d.query = "" | |
| 169 | 176 | d.searching = false | |
| 177 | + | d.searchPending = false | |
| 170 | 178 | d.searchReady = false | |
| 171 | 179 | d.searchSegments = nil | |
| 172 | 180 | d.searchMatches = nil | |
| @@ -291,6 +299,10 @@ func (d *sessionDialog) ApplyLoad(event sessionLoadEvent) { | |||
| 291 | 299 | d.loadingTotal = event.total | |
| 292 | 300 | d.loadingDone = 0 | |
| 293 | 301 | d.paths = append([]string(nil), event.paths...) | |
| 302 | + | if d.searchPending { | |
| 303 | + | d.searchPending = false | |
| 304 | + | d.startSearchReader() | |
| 305 | + | } | |
| 294 | 306 | d.loadSlots = make([]sessionLoadSlot, event.total) | |
| 295 | 307 | d.loadReadyThrough = 0 | |
| 296 | 308 | d.sessions = nil | |
| @@ -411,6 +423,10 @@ func (d *sessionDialog) Close() { | |||
| 411 | 423 | d.searchCancel() | |
| 412 | 424 | d.searchCancel = nil | |
| 413 | 425 | } | |
| 426 | + | if d.matchCancel != nil { | |
| 427 | + | d.matchCancel() | |
| 428 | + | d.matchCancel = nil | |
| 429 | + | } | |
| 414 | 430 | d.loading = false | |
| 415 | 431 | d.searching = false | |
| 416 | 432 | d.loadSlots = nil | |
| @@ -429,13 +445,28 @@ func (d *sessionDialog) StartSearch(parent context.Context) <-chan sessionSearch | |||
| 429 | 445 | parent = context.Background() | |
| 430 | 446 | } | |
| 431 | 447 | ctx, cancel := context.WithCancel(parent) | |
| 448 | + | d.searchContext = ctx | |
| 432 | 449 | d.searchCancel = cancel | |
| 433 | 450 | d.searching = true | |
| 434 | 451 | d.searchGeneration++ | |
| 435 | - | generation := d.searchGeneration | |
| 436 | 452 | events := make(chan sessionSearchEvent, 1) | |
| 437 | 453 | d.searchEvents = events | |
| 454 | + | if len(d.paths) == 0 && d.loading { | |
| 455 | + | d.searchPending = true | |
| 456 | + | return events | |
| 457 | + | } | |
| 458 | + | d.startSearchReader() | |
| 459 | + | return events | |
| 460 | + | } | |
| 461 | + | ||
| 462 | + | func (d *sessionDialog) startSearchReader() { | |
| 463 | + | if d.searchEvents == nil || d.searchCancel == nil { | |
| 464 | + | return | |
| 465 | + | } | |
| 466 | + | ctx := d.searchContext | |
| 438 | 467 | paths := append([]string(nil), d.paths...) | |
| 468 | + | generation := d.searchGeneration | |
| 469 | + | events := d.searchEvents | |
| 439 | 470 | go func() { | |
| 440 | 471 | segments := make([]core.SessionSearchSegment, 0) | |
| 441 | 472 | skipped := 0 | |
| @@ -458,7 +489,6 @@ func (d *sessionDialog) StartSearch(parent context.Context) <-chan sessionSearch | |||
| 458 | 489 | case <-ctx.Done(): | |
| 459 | 490 | } | |
| 460 | 491 | }() | |
| 461 | - | return events | |
| 462 | 492 | } | |
| 463 | 493 | ||
| 464 | 494 | // ApplySearch incorporates corpus and match results on the UI goroutine. | |
| @@ -488,15 +518,23 @@ func (d *sessionDialog) scheduleSearchMatch() { | |||
| 488 | 518 | if !d.searchReady || d.query == "" || d.searchEvents == nil || d.searchCancel == nil { | |
| 489 | 519 | return | |
| 490 | 520 | } | |
| 521 | + | if d.matchCancel != nil { | |
| 522 | + | d.matchCancel() | |
| 523 | + | } | |
| 491 | 524 | query := d.query | |
| 492 | 525 | segments := append([]core.SessionSearchSegment(nil), d.searchSegments...) | |
| 493 | 526 | generation := d.searchGeneration | |
| 494 | - | ctx := context.Background() | |
| 527 | + | events := d.searchEvents | |
| 528 | + | ctx, cancel := context.WithCancel(d.searchContext) | |
| 529 | + | d.matchCancel = cancel | |
| 495 | 530 | go func() { | |
| 496 | 531 | matches := matchSessionSearchSegments(ctx, query, segments) | |
| 532 | + | if ctx.Err() != nil { | |
| 533 | + | return | |
| 534 | + | } | |
| 497 | 535 | select { | |
| 498 | - | case d.searchEvents <- sessionSearchEvent{kind: sessionSearchMatchesReady, generation: generation, query: query, matches: matches}: | |
| 499 | - | default: | |
| 536 | + | case events <- sessionSearchEvent{kind: sessionSearchMatchesReady, generation: generation, query: query, matches: matches}: | |
| 537 | + | case <-ctx.Done(): | |
| 500 | 538 | } | |
| 501 | 539 | }() | |
| 502 | 540 | } | |
| @@ -647,7 +685,8 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |||
| 647 | 685 | lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" ↓ %d more below", hidden))) | |
| 648 | 686 | } | |
| 649 | 687 | if d.allScope && d.cursor >= 0 && d.cursor < len(d.sessions) && d.sessions[d.cursor].CWD != "" { | |
| 650 | - | lines = append(lines, th.FGColor(th.Muted, " cwd: "+friendlyPath(d.sessions[d.cursor].CWD))) | |
| 688 | + | cwd := sanitizeSessionTreeText(friendlyPath(d.sessions[d.cursor].CWD)) | |
| 689 | + | lines = append(lines, th.FGColor(th.Muted, " cwd: "+cwd)) | |
| 651 | 690 | } | |
| 652 | 691 | if d.query != "" && d.cursor >= 0 && d.cursor < len(d.sessions) { | |
| 653 | 692 | if match := d.searchMatches[d.sessions[d.cursor].Path]; match.excerpt != "" { | |
| @@ -837,6 +876,12 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { | |||
| 837 | 876 | renameErr = err | |
| 838 | 877 | } else { | |
| 839 | 878 | d.sessions[d.cursor].Title = title | |
| 879 | + | for index := range d.baseSessions { | |
| 880 | + | if d.baseSessions[index].Path == path { | |
| 881 | + | d.baseSessions[index].Title = title | |
| 882 | + | break | |
| 883 | + | } | |
| 884 | + | } | |
| 840 | 885 | renamed = true | |
| 841 | 886 | } | |
| 842 | 887 | } | |
| @@ -874,6 +919,10 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { | |||
| 874 | 919 | if d.query != "" || d.searchEvents != nil { | |
| 875 | 920 | switch k.Kind { | |
| 876 | 921 | case tui.KeyEsc: | |
| 922 | + | if d.query == "" { | |
| 923 | + | d.Close() | |
| 924 | + | return sessionDialogAction{Close: true} | |
| 925 | + | } | |
| 877 | 926 | d.query = "" | |
| 878 | 927 | d.searchMatches = nil | |
| 879 | 928 | d.applySearchFilter() | |
| @@ -894,8 +943,10 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { | |||
| 894 | 943 | d.query += string(k.Rune) | |
| 895 | 944 | } | |
| 896 | 945 | default: | |
| 897 | - | return sessionDialogAction{} | |
| 946 | + | // Navigation and selection operate on the current filtered rows. | |
| 947 | + | goto navigate | |
| 898 | 948 | } | |
| 949 | + | d.query = sanitizeSessionTreeText(d.query) | |
| 899 | 950 | if len([]rune(d.query)) > 256 { | |
| 900 | 951 | d.query = string([]rune(d.query)[:256]) | |
| 901 | 952 | } | |
| @@ -913,6 +964,7 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { | |||
| 913 | 964 | return sessionDialogAction{} | |
| 914 | 965 | } | |
| 915 | 966 | ||
| 967 | + | navigate: | |
| 916 | 968 | page := d.MaxRows | |
| 917 | 969 | if page <= 0 { | |
| 918 | 970 | page = 10 | |
| @@ -80,9 +80,12 @@ type sessionDialog struct { | |||||
| 80 | paths []string | 80 | paths []string | ||
| 81 | query string | 81 | query string | ||
| 82 | searching bool | 82 | searching bool | ||
| 83 | + | searchPending bool | |||
| 83 | searchReady bool | 84 | searchReady bool | ||
| 84 | searchGeneration uint64 | 85 | searchGeneration uint64 | ||
| 86 | + | searchContext context.Context | |||
| 85 | searchCancel context.CancelFunc | 87 | searchCancel context.CancelFunc | ||
| 88 | + | matchCancel context.CancelFunc | |||
| 86 | searchEvents chan sessionSearchEvent | 89 | searchEvents chan sessionSearchEvent | ||
| 87 | searchSegments []core.SessionSearchSegment | 90 | searchSegments []core.SessionSearchSegment | ||
| 88 | searchMatches map[string]sessionSearchMatch | 91 | searchMatches map[string]sessionSearchMatch | ||
| @@ -145,6 +148,10 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string, allScope | |||||
| 145 | d.searchCancel() | 148 | d.searchCancel() | ||
| 146 | d.searchCancel = nil | 149 | d.searchCancel = nil | ||
| 147 | } | 150 | } | ||
| 151 | + | if d.matchCancel != nil { | |||
| 152 | + | d.matchCancel() | |||
| 153 | + | d.matchCancel = nil | |||
| 154 | + | } | |||
| 148 | previousDone := d.loadDone | 155 | previousDone := d.loadDone | ||
| 149 | if parent == nil { | 156 | if parent == nil { | ||
| 150 | parent = context.Background() | 157 | parent = context.Background() | ||
| @@ -167,6 +174,7 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string, allScope | |||||
| 167 | d.paths = nil | 174 | d.paths = nil | ||
| 168 | d.query = "" | 175 | d.query = "" | ||
| 169 | d.searching = false | 176 | d.searching = false | ||
| 177 | + | d.searchPending = false | |||
| 170 | d.searchReady = false | 178 | d.searchReady = false | ||
| 171 | d.searchSegments = nil | 179 | d.searchSegments = nil | ||
| 172 | d.searchMatches = nil | 180 | d.searchMatches = nil | ||
| @@ -291,6 +299,10 @@ func (d *sessionDialog) ApplyLoad(event sessionLoadEvent) { | |||||
| 291 | d.loadingTotal = event.total | 299 | d.loadingTotal = event.total | ||
| 292 | d.loadingDone = 0 | 300 | d.loadingDone = 0 | ||
| 293 | d.paths = append([]string(nil), event.paths...) | 301 | d.paths = append([]string(nil), event.paths...) | ||
| 302 | + | if d.searchPending { | |||
| 303 | + | d.searchPending = false | |||
| 304 | + | d.startSearchReader() | |||
| 305 | + | } | |||
| 294 | d.loadSlots = make([]sessionLoadSlot, event.total) | 306 | d.loadSlots = make([]sessionLoadSlot, event.total) | ||
| 295 | d.loadReadyThrough = 0 | 307 | d.loadReadyThrough = 0 | ||
| 296 | d.sessions = nil | 308 | d.sessions = nil | ||
| @@ -411,6 +423,10 @@ func (d *sessionDialog) Close() { | |||||
| 411 | d.searchCancel() | 423 | d.searchCancel() | ||
| 412 | d.searchCancel = nil | 424 | d.searchCancel = nil | ||
| 413 | } | 425 | } | ||
| 426 | + | if d.matchCancel != nil { | |||
| 427 | + | d.matchCancel() | |||
| 428 | + | d.matchCancel = nil | |||
| 429 | + | } | |||
| 414 | d.loading = false | 430 | d.loading = false | ||
| 415 | d.searching = false | 431 | d.searching = false | ||
| 416 | d.loadSlots = nil | 432 | d.loadSlots = nil | ||
| @@ -429,13 +445,28 @@ func (d *sessionDialog) StartSearch(parent context.Context) <-chan sessionSearch | |||||
| 429 | parent = context.Background() | 445 | parent = context.Background() | ||
| 430 | } | 446 | } | ||
| 431 | ctx, cancel := context.WithCancel(parent) | 447 | ctx, cancel := context.WithCancel(parent) | ||
| 448 | + | d.searchContext = ctx | |||
| 432 | d.searchCancel = cancel | 449 | d.searchCancel = cancel | ||
| 433 | d.searching = true | 450 | d.searching = true | ||
| 434 | d.searchGeneration++ | 451 | d.searchGeneration++ | ||
| 435 | - | generation := d.searchGeneration | |||
| 436 | events := make(chan sessionSearchEvent, 1) | 452 | events := make(chan sessionSearchEvent, 1) | ||
| 437 | d.searchEvents = events | 453 | d.searchEvents = events | ||
| 454 | + | if len(d.paths) == 0 && d.loading { | |||
| 455 | + | d.searchPending = true | |||
| 456 | + | return events | |||
| 457 | + | } | |||
| 458 | + | d.startSearchReader() | |||
| 459 | + | return events | |||
| 460 | + | } | |||
| 461 | + | ||||
| 462 | + | func (d *sessionDialog) startSearchReader() { | |||
| 463 | + | if d.searchEvents == nil || d.searchCancel == nil { | |||
| 464 | + | return | |||
| 465 | + | } | |||
| 466 | + | ctx := d.searchContext | |||
| 438 | paths := append([]string(nil), d.paths...) | 467 | paths := append([]string(nil), d.paths...) | ||
| 468 | + | generation := d.searchGeneration | |||
| 469 | + | events := d.searchEvents | |||
| 439 | go func() { | 470 | go func() { | ||
| 440 | segments := make([]core.SessionSearchSegment, 0) | 471 | segments := make([]core.SessionSearchSegment, 0) | ||
| 441 | skipped := 0 | 472 | skipped := 0 | ||
| @@ -458,7 +489,6 @@ func (d *sessionDialog) StartSearch(parent context.Context) <-chan sessionSearch | |||||
| 458 | case <-ctx.Done(): | 489 | case <-ctx.Done(): | ||
| 459 | } | 490 | } | ||
| 460 | }() | 491 | }() | ||
| 461 | - | return events | |||
| 462 | } | 492 | } | ||
| 463 | 493 | ||||
| 464 | // ApplySearch incorporates corpus and match results on the UI goroutine. | 494 | // ApplySearch incorporates corpus and match results on the UI goroutine. | ||
| @@ -488,15 +518,23 @@ func (d *sessionDialog) scheduleSearchMatch() { | |||||
| 488 | if !d.searchReady || d.query == "" || d.searchEvents == nil || d.searchCancel == nil { | 518 | if !d.searchReady || d.query == "" || d.searchEvents == nil || d.searchCancel == nil { | ||
| 489 | return | 519 | return | ||
| 490 | } | 520 | } | ||
| 521 | + | if d.matchCancel != nil { | |||
| 522 | + | d.matchCancel() | |||
| 523 | + | } | |||
| 491 | query := d.query | 524 | query := d.query | ||
| 492 | segments := append([]core.SessionSearchSegment(nil), d.searchSegments...) | 525 | segments := append([]core.SessionSearchSegment(nil), d.searchSegments...) | ||
| 493 | generation := d.searchGeneration | 526 | generation := d.searchGeneration | ||
| 494 | - | ctx := context.Background() | 527 | + | events := d.searchEvents |
| 528 | + | ctx, cancel := context.WithCancel(d.searchContext) | |||
| 529 | + | d.matchCancel = cancel | |||
| 495 | go func() { | 530 | go func() { | ||
| 496 | matches := matchSessionSearchSegments(ctx, query, segments) | 531 | matches := matchSessionSearchSegments(ctx, query, segments) | ||
| 532 | + | if ctx.Err() != nil { | |||
| 533 | + | return | |||
| 534 | + | } | |||
| 497 | select { | 535 | select { | ||
| 498 | - | case d.searchEvents <- sessionSearchEvent{kind: sessionSearchMatchesReady, generation: generation, query: query, matches: matches}: | 536 | + | case events <- sessionSearchEvent{kind: sessionSearchMatchesReady, generation: generation, query: query, matches: matches}: |
| 499 | - | default: | 537 | + | case <-ctx.Done(): |
| 500 | } | 538 | } | ||
| 501 | }() | 539 | }() | ||
| 502 | } | 540 | } | ||
| @@ -647,7 +685,8 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |||||
| 647 | lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" ↓ %d more below", hidden))) | 685 | lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" ↓ %d more below", hidden))) | ||
| 648 | } | 686 | } | ||
| 649 | if d.allScope && d.cursor >= 0 && d.cursor < len(d.sessions) && d.sessions[d.cursor].CWD != "" { | 687 | if d.allScope && d.cursor >= 0 && d.cursor < len(d.sessions) && d.sessions[d.cursor].CWD != "" { | ||
| 650 | - | lines = append(lines, th.FGColor(th.Muted, " cwd: "+friendlyPath(d.sessions[d.cursor].CWD))) | 688 | + | cwd := sanitizeSessionTreeText(friendlyPath(d.sessions[d.cursor].CWD)) |
| 689 | + | lines = append(lines, th.FGColor(th.Muted, " cwd: "+cwd)) | |||
| 651 | } | 690 | } | ||
| 652 | if d.query != "" && d.cursor >= 0 && d.cursor < len(d.sessions) { | 691 | if d.query != "" && d.cursor >= 0 && d.cursor < len(d.sessions) { | ||
| 653 | if match := d.searchMatches[d.sessions[d.cursor].Path]; match.excerpt != "" { | 692 | if match := d.searchMatches[d.sessions[d.cursor].Path]; match.excerpt != "" { | ||
| @@ -837,6 +876,12 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { | |||||
| 837 | renameErr = err | 876 | renameErr = err | ||
| 838 | } else { | 877 | } else { | ||
| 839 | d.sessions[d.cursor].Title = title | 878 | d.sessions[d.cursor].Title = title | ||
| 879 | + | for index := range d.baseSessions { | |||
| 880 | + | if d.baseSessions[index].Path == path { | |||
| 881 | + | d.baseSessions[index].Title = title | |||
| 882 | + | break | |||
| 883 | + | } | |||
| 884 | + | } | |||
| 840 | renamed = true | 885 | renamed = true | ||
| 841 | } | 886 | } | ||
| 842 | } | 887 | } | ||
| @@ -874,6 +919,10 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { | |||||
| 874 | if d.query != "" || d.searchEvents != nil { | 919 | if d.query != "" || d.searchEvents != nil { | ||
| 875 | switch k.Kind { | 920 | switch k.Kind { | ||
| 876 | case tui.KeyEsc: | 921 | case tui.KeyEsc: | ||
| 922 | + | if d.query == "" { | |||
| 923 | + | d.Close() | |||
| 924 | + | return sessionDialogAction{Close: true} | |||
| 925 | + | } | |||
| 877 | d.query = "" | 926 | d.query = "" | ||
| 878 | d.searchMatches = nil | 927 | d.searchMatches = nil | ||
| 879 | d.applySearchFilter() | 928 | d.applySearchFilter() | ||
| @@ -894,8 +943,10 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { | |||||
| 894 | d.query += string(k.Rune) | 943 | d.query += string(k.Rune) | ||
| 895 | } | 944 | } | ||
| 896 | default: | 945 | default: | ||
| 897 | - | return sessionDialogAction{} | 946 | + | // Navigation and selection operate on the current filtered rows. |
| 947 | + | goto navigate | |||
| 898 | } | 948 | } | ||
| 949 | + | d.query = sanitizeSessionTreeText(d.query) | |||
| 899 | if len([]rune(d.query)) > 256 { | 950 | if len([]rune(d.query)) > 256 { | ||
| 900 | d.query = string([]rune(d.query)[:256]) | 951 | d.query = string([]rune(d.query)[:256]) | ||
| 901 | } | 952 | } | ||
| @@ -913,6 +964,7 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { | |||||
| 913 | return sessionDialogAction{} | 964 | return sessionDialogAction{} | ||
| 914 | } | 965 | } | ||
| 915 | 966 | ||||
| 967 | + | navigate: | |||
| 916 | page := d.MaxRows | 968 | page := d.MaxRows | ||
| 917 | if page <= 0 { | 969 | if page <= 0 { | ||
| 918 | page = 10 | 970 | page = 10 | ||
M packages/core/session_picker.go +10 -1
| @@ -60,7 +60,12 @@ func ListSessionPathsContext(ctx context.Context, root, cwd string, all bool) [] | |||
| 60 | 60 | dirs = append(dirs, filepath.Join(sessionsDir, entry.Name())) | |
| 61 | 61 | } | |
| 62 | 62 | } else { | |
| 63 | - | dirs = []string{SessionsDir(root, cwd)} | |
| 63 | + | dir := SessionsDir(root, cwd) | |
| 64 | + | info, err := os.Lstat(dir) | |
| 65 | + | if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { | |
| 66 | + | return nil | |
| 67 | + | } | |
| 68 | + | dirs = []string{dir} | |
| 64 | 69 | } | |
| 65 | 70 | ||
| 66 | 71 | type candidate struct { | |
| @@ -139,6 +144,10 @@ func ManagedSessionMeta(ctx context.Context, root, path string) (SessionMeta, er | |||
| 139 | 144 | return SessionMeta{}, errors.New("managed session: invalid session metadata") | |
| 140 | 145 | } | |
| 141 | 146 | wantDir := filepath.Clean(SessionsDir(root, meta.CWD)) | |
| 147 | + | dirInfo, err := os.Lstat(wantDir) | |
| 148 | + | if err != nil || dirInfo.Mode()&os.ModeSymlink != 0 || !dirInfo.IsDir() { | |
| 149 | + | return SessionMeta{}, errors.New("managed session: cwd bucket is not a directory") | |
| 150 | + | } | |
| 142 | 151 | if filepath.Clean(filepath.Dir(path)) != wantDir { | |
| 143 | 152 | return SessionMeta{}, errors.New("managed session: cwd bucket does not match metadata") | |
| 144 | 153 | } | |
| @@ -60,7 +60,12 @@ func ListSessionPathsContext(ctx context.Context, root, cwd string, all bool) [] | |||||
| 60 | dirs = append(dirs, filepath.Join(sessionsDir, entry.Name())) | 60 | dirs = append(dirs, filepath.Join(sessionsDir, entry.Name())) | ||
| 61 | } | 61 | } | ||
| 62 | } else { | 62 | } else { | ||
| 63 | - | dirs = []string{SessionsDir(root, cwd)} | 63 | + | dir := SessionsDir(root, cwd) |
| 64 | + | info, err := os.Lstat(dir) | |||
| 65 | + | if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { | |||
| 66 | + | return nil | |||
| 67 | + | } | |||
| 68 | + | dirs = []string{dir} | |||
| 64 | } | 69 | } | ||
| 65 | 70 | ||||
| 66 | type candidate struct { | 71 | type candidate struct { | ||
| @@ -139,6 +144,10 @@ func ManagedSessionMeta(ctx context.Context, root, path string) (SessionMeta, er | |||||
| 139 | return SessionMeta{}, errors.New("managed session: invalid session metadata") | 144 | return SessionMeta{}, errors.New("managed session: invalid session metadata") | ||
| 140 | } | 145 | } | ||
| 141 | wantDir := filepath.Clean(SessionsDir(root, meta.CWD)) | 146 | wantDir := filepath.Clean(SessionsDir(root, meta.CWD)) | ||
| 147 | + | dirInfo, err := os.Lstat(wantDir) | |||
| 148 | + | if err != nil || dirInfo.Mode()&os.ModeSymlink != 0 || !dirInfo.IsDir() { | |||
| 149 | + | return SessionMeta{}, errors.New("managed session: cwd bucket is not a directory") | |||
| 150 | + | } | |||
| 142 | if filepath.Clean(filepath.Dir(path)) != wantDir { | 151 | if filepath.Clean(filepath.Dir(path)) != wantDir { | ||
| 143 | return SessionMeta{}, errors.New("managed session: cwd bucket does not match metadata") | 152 | return SessionMeta{}, errors.New("managed session: cwd bucket does not match metadata") | ||
| 144 | } | 153 | } | ||