Skip to main content

ccs/tui/
search_mode.rs

1use crate::search::{RipgrepMatch, SessionGroup};
2use crate::tui::App;
3use std::time::Instant;
4
5impl App {
6    /// Whether the app is currently showing the recent sessions list
7    /// (input is empty, no search results, not loading search).
8    pub fn in_recent_sessions_mode(&self) -> bool {
9        self.input.is_empty() && self.groups.is_empty()
10    }
11
12    pub fn on_up(&mut self) {
13        // Recent sessions navigation
14        if self.in_recent_sessions_mode() {
15            if !self.recent_sessions.is_empty() && self.recent_cursor > 0 {
16                self.recent_cursor -= 1;
17            }
18            return;
19        }
20
21        if self.groups.is_empty() {
22            return;
23        }
24
25        let old_cursor = (self.group_cursor, self.sub_cursor);
26
27        if self.expanded && self.sub_cursor > 0 {
28            self.sub_cursor -= 1;
29        } else if self.group_cursor > 0 {
30            self.group_cursor -= 1;
31            self.sub_cursor = 0;
32            self.expanded = false;
33        }
34
35        // Force full redraw in preview mode when selection changed
36        if self.preview_mode && (self.group_cursor, self.sub_cursor) != old_cursor {
37            self.needs_full_redraw = true;
38        }
39    }
40
41    pub fn on_down(&mut self) {
42        // Recent sessions navigation
43        if self.in_recent_sessions_mode() {
44            if !self.recent_sessions.is_empty()
45                && self.recent_cursor < self.recent_sessions.len().saturating_sub(1)
46            {
47                self.recent_cursor += 1;
48            }
49            return;
50        }
51
52        if self.groups.is_empty() {
53            return;
54        }
55
56        let old_cursor = (self.group_cursor, self.sub_cursor);
57
58        if self.expanded {
59            if let Some(group) = self.selected_group() {
60                if self.sub_cursor < group.matches.len().saturating_sub(1) {
61                    self.sub_cursor += 1;
62                    // Force full redraw in preview mode
63                    if self.preview_mode {
64                        self.needs_full_redraw = true;
65                    }
66                    return;
67                }
68            }
69        }
70
71        if self.group_cursor < self.groups.len().saturating_sub(1) {
72            self.group_cursor += 1;
73            self.sub_cursor = 0;
74            self.expanded = false;
75        }
76
77        // Force full redraw in preview mode when selection changed
78        if self.preview_mode && (self.group_cursor, self.sub_cursor) != old_cursor {
79            self.needs_full_redraw = true;
80        }
81    }
82
83    pub fn on_right(&mut self) {
84        if self.cursor_pos < self.input.len() {
85            self.move_cursor_right();
86        } else if !self.groups.is_empty() && self.group_cursor < self.groups.len() {
87            self.expanded = true;
88            // Precompute latest chain for the expanded group (for fork indicator)
89            if let Some(group) = self.groups.get(self.group_cursor) {
90                let fp = group.file_path.clone();
91                if let std::collections::hash_map::Entry::Vacant(e) =
92                    self.latest_chains.entry(fp.clone())
93                {
94                    if let Some(chain) = crate::resume::build_chain_from_tip(&fp) {
95                        e.insert(chain);
96                    }
97                }
98            }
99        }
100    }
101
102    pub fn on_left(&mut self) {
103        if self.expanded {
104            self.expanded = false;
105            self.sub_cursor = 0;
106        } else if self.cursor_pos > 0 {
107            self.move_cursor_left();
108        } else {
109            self.expanded = false;
110            self.sub_cursor = 0;
111        }
112    }
113
114    pub fn on_tab(&mut self) {
115        if !self.groups.is_empty() && self.selected_match().is_some() {
116            self.preview_mode = !self.preview_mode;
117            // Force full redraw when toggling preview mode
118            self.needs_full_redraw = true;
119        }
120    }
121
122    pub fn on_toggle_regex(&mut self) {
123        self.regex_mode = !self.regex_mode;
124        // Trigger re-search if we have a query
125        if !self.input.is_empty() {
126            self.last_keystroke = Some(Instant::now());
127            self.typing = true;
128        }
129    }
130
131    pub fn toggle_automation_filter(&mut self) {
132        use crate::tui::state::AutomationFilter;
133        self.automation_filter = match self.automation_filter {
134            AutomationFilter::All => AutomationFilter::Manual,
135            AutomationFilter::Manual => AutomationFilter::Auto,
136            AutomationFilter::Auto => AutomationFilter::All,
137        };
138        self.apply_recent_sessions_filter();
139        self.apply_groups_filter();
140        self.recent_cursor = 0;
141        self.recent_scroll_offset = 0;
142        self.group_cursor = 0;
143        self.sub_cursor = 0;
144        self.expanded = false;
145    }
146
147    pub fn toggle_project_filter(&mut self) {
148        if self.current_project_paths.is_empty() {
149            return;
150        }
151        self.project_filter = !self.project_filter;
152        self.search_paths = if self.project_filter {
153            self.current_project_paths.clone()
154        } else {
155            self.all_search_paths.clone()
156        };
157        // Refilter recent sessions list
158        self.apply_recent_sessions_filter();
159        if !self.input.is_empty() {
160            self.last_keystroke = Some(Instant::now());
161            self.typing = true;
162        }
163    }
164
165    pub fn on_enter(&mut self) {
166        if self.preview_mode {
167            self.preview_mode = false;
168            return;
169        }
170
171        // Recent sessions: resume selected session
172        if self.in_recent_sessions_mode() {
173            if let Some(session) = self.recent_sessions.get(self.recent_cursor) {
174                self.resume_id = Some(session.session_id.clone());
175                self.resume_file_path = Some(session.file_path.clone());
176                self.resume_source = Some(session.source);
177                self.resume_uuid = None;
178                self.should_quit = true;
179            }
180            return;
181        }
182
183        // Extract values first to avoid borrow issues
184        let resume_info = self.selected_match().and_then(|m| {
185            m.message.as_ref().map(|msg| {
186                (
187                    msg.session_id.clone(),
188                    m.file_path.clone(),
189                    m.source,
190                    msg.uuid.clone(),
191                )
192            })
193        });
194
195        if let Some((session_id, file_path, source, uuid)) = resume_info {
196            self.resume_id = Some(session_id);
197            self.resume_file_path = Some(file_path);
198            self.resume_source = Some(source);
199            self.resume_uuid = uuid;
200            self.should_quit = true;
201        }
202    }
203
204    /// Enter tree mode for the currently selected recent session.
205    pub fn enter_tree_mode_recent(&mut self) {
206        if let Some(session) = self.recent_sessions.get(self.recent_cursor) {
207            let file_path = session.file_path.clone();
208            self.enter_tree_mode_for_file(&file_path);
209        }
210    }
211
212    pub fn selected_group(&self) -> Option<&SessionGroup> {
213        self.groups.get(self.group_cursor)
214    }
215
216    pub fn selected_match(&self) -> Option<&RipgrepMatch> {
217        self.selected_group()
218            .and_then(|g| g.matches.get(self.sub_cursor))
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use crate::recent::RecentSession;
225    use crate::search::{RipgrepMatch, SessionGroup, SessionSource};
226    use crate::tui::state::DEBOUNCE_MS;
227    use crate::tui::App;
228    use chrono::Utc;
229    use std::time::{Duration, Instant};
230
231    fn make_recent_session(id: &str, project: &str, summary: &str) -> RecentSession {
232        RecentSession {
233            session_id: id.to_string(),
234            file_path: format!("/tmp/{}.jsonl", id),
235            project: project.to_string(),
236            source: SessionSource::ClaudeCodeCLI,
237            timestamp: Utc::now(),
238            summary: summary.to_string(),
239            automation: None,
240        }
241    }
242
243    #[test]
244    fn test_navigation_empty_groups() {
245        let mut app = App::new(vec!["/test".to_string()]);
246
247        // Should not panic with empty groups
248        app.on_up();
249        app.on_down();
250        app.on_left();
251        app.on_right();
252
253        assert_eq!(app.group_cursor, 0);
254    }
255
256    #[test]
257    fn test_expand_collapse() {
258        let mut app = App::new(vec!["/test".to_string()]);
259
260        // Setup some groups
261        app.groups = vec![SessionGroup {
262            session_id: "test".to_string(),
263            file_path: "/test.jsonl".to_string(),
264            matches: vec![],
265            automation: None,
266        }];
267
268        app.on_right();
269        assert!(app.expanded);
270
271        app.on_left();
272        assert!(!app.expanded);
273    }
274
275    #[test]
276    fn test_left_collapses_expanded_group_even_with_input_cursor() {
277        let mut app = App::new(vec!["/test".to_string()]);
278        app.groups = vec![SessionGroup {
279            session_id: "test".to_string(),
280            file_path: "/test.jsonl".to_string(),
281            matches: vec![],
282            automation: None,
283        }];
284        app.input = "query".to_string();
285        app.cursor_pos = app.input.len();
286        app.expanded = true;
287        app.sub_cursor = 1;
288
289        app.on_left();
290
291        assert!(!app.expanded);
292        assert_eq!(app.sub_cursor, 0);
293        assert_eq!(app.cursor_pos, 5);
294    }
295
296    #[test]
297    fn test_preview_toggle() {
298        let mut app = App::new(vec!["/test".to_string()]);
299
300        // Without groups, preview should not toggle
301        app.on_tab();
302        assert!(!app.preview_mode);
303    }
304
305    #[test]
306    fn test_toggle_project_filter_no_current_project() {
307        let mut app = App::new(vec!["/test".to_string()]);
308        assert!(!app.project_filter);
309        app.toggle_project_filter();
310        assert!(!app.project_filter); // unchanged — no current project detected
311    }
312
313    #[test]
314    fn test_toggle_project_filter_switches_paths() {
315        let mut app = App::new(vec!["/all".to_string()]);
316        app.current_project_paths = vec!["/all/-Users-test-project".to_string()];
317
318        assert!(!app.project_filter);
319        assert_eq!(app.search_paths, vec!["/all".to_string()]);
320
321        app.toggle_project_filter();
322        assert!(app.project_filter);
323        assert_eq!(
324            app.search_paths,
325            vec!["/all/-Users-test-project".to_string()]
326        );
327
328        app.toggle_project_filter();
329        assert!(!app.project_filter);
330        assert_eq!(app.search_paths, vec!["/all".to_string()]);
331    }
332
333    #[test]
334    fn test_toggle_project_filter_triggers_research() {
335        let mut app = App::new(vec!["/all".to_string()]);
336        app.current_project_paths = vec!["/all/-Users-test".to_string()];
337        app.input = "query".to_string();
338        app.last_query = "query".to_string();
339        app.cursor_pos = 5;
340
341        app.toggle_project_filter();
342        app.last_keystroke = Some(Instant::now() - Duration::from_millis(DEBOUNCE_MS + 1));
343        app.tick();
344
345        assert!(app.searching);
346        assert_eq!(app.search_seq, 1);
347        assert_eq!(app.last_search_paths, vec!["/all/-Users-test".to_string()]);
348    }
349
350    #[test]
351    fn test_toggle_project_filter_no_research_empty_query() {
352        let mut app = App::new(vec!["/all".to_string()]);
353        app.current_project_paths = vec!["/all/-Users-test".to_string()];
354
355        app.toggle_project_filter();
356
357        assert!(app.project_filter);
358        assert!(!app.typing);
359    }
360
361    #[test]
362    fn test_stale_search_result_ignored_when_scope_changes() {
363        let mut app = App::new(vec!["/all".to_string()]);
364        app.input = "query".to_string();
365        app.search_paths = vec!["/project".to_string()];
366        app.search_seq = 1;
367        app.searching = true;
368
369        let stale_result = (
370            1,
371            "query".to_string(),
372            vec!["/all".to_string()],
373            false,
374            Ok(vec![RipgrepMatch {
375                file_path: "/all/session.jsonl".to_string(),
376                message: None,
377                source: SessionSource::ClaudeCodeCLI,
378            }]),
379        );
380
381        app.handle_search_result(stale_result);
382
383        assert!(app.results.is_empty());
384        assert!(app.groups.is_empty());
385        assert!(app.searching);
386    }
387
388    // =========================================================================
389    // Recent sessions navigation tests
390    // =========================================================================
391
392    #[test]
393    fn test_in_recent_sessions_mode() {
394        let mut app = App::new(vec!["/test".to_string()]);
395        // Empty input, empty groups → recent sessions mode
396        assert!(app.in_recent_sessions_mode());
397
398        // Typing something exits recent sessions mode
399        app.on_key('h');
400        assert!(!app.in_recent_sessions_mode());
401
402        // Clearing input returns to recent sessions mode
403        app.clear_input();
404        assert!(app.in_recent_sessions_mode());
405    }
406
407    #[test]
408    fn test_in_recent_sessions_mode_false_when_groups_present() {
409        let mut app = App::new(vec!["/test".to_string()]);
410        app.groups = vec![SessionGroup {
411            session_id: "test".to_string(),
412            file_path: "/test.jsonl".to_string(),
413            matches: vec![],
414            automation: None,
415        }];
416        // Even with empty input, if groups exist we're in search results mode
417        assert!(!app.in_recent_sessions_mode());
418    }
419
420    #[test]
421    fn test_recent_sessions_up_down_navigation() {
422        let mut app = App::new(vec!["/test".to_string()]);
423        app.recent_loading = false;
424        app.recent_sessions = vec![
425            make_recent_session("s1", "proj-a", "first message"),
426            make_recent_session("s2", "proj-b", "second message"),
427            make_recent_session("s3", "proj-c", "third message"),
428        ];
429
430        assert_eq!(app.recent_cursor, 0);
431
432        app.on_down();
433        assert_eq!(app.recent_cursor, 1);
434
435        app.on_down();
436        assert_eq!(app.recent_cursor, 2);
437
438        // At bottom, should not go further
439        app.on_down();
440        assert_eq!(app.recent_cursor, 2);
441
442        app.on_up();
443        assert_eq!(app.recent_cursor, 1);
444
445        app.on_up();
446        assert_eq!(app.recent_cursor, 0);
447
448        // At top, should not go further
449        app.on_up();
450        assert_eq!(app.recent_cursor, 0);
451    }
452
453    #[test]
454    fn test_recent_sessions_navigation_empty_list() {
455        let mut app = App::new(vec!["/test".to_string()]);
456        app.recent_loading = false;
457        app.recent_sessions = vec![];
458
459        // Should not panic or change cursor
460        app.on_up();
461        assert_eq!(app.recent_cursor, 0);
462        app.on_down();
463        assert_eq!(app.recent_cursor, 0);
464    }
465
466    #[test]
467    fn test_recent_sessions_navigation_while_loading() {
468        let mut app = App::new(vec!["/test".to_string()]);
469        // recent_loading is true by default, recent_sessions is empty
470        assert!(app.recent_loading);
471
472        // Navigation should not panic
473        app.on_up();
474        app.on_down();
475        assert_eq!(app.recent_cursor, 0);
476    }
477
478    #[test]
479    fn test_recent_sessions_enter_resumes_session() {
480        let mut app = App::new(vec!["/test".to_string()]);
481        app.recent_loading = false;
482        app.recent_sessions = vec![
483            make_recent_session("s1", "proj-a", "first"),
484            make_recent_session("s2", "proj-b", "second"),
485        ];
486
487        // Navigate to second session and press Enter
488        app.on_down();
489        app.on_enter();
490
491        assert!(app.should_quit);
492        assert_eq!(app.resume_id.as_deref(), Some("s2"));
493        assert_eq!(app.resume_file_path.as_deref(), Some("/tmp/s2.jsonl"));
494        assert_eq!(app.resume_source, Some(SessionSource::ClaudeCodeCLI));
495        assert!(app.resume_uuid.is_none());
496    }
497
498    #[test]
499    fn test_recent_sessions_enter_on_empty_list_does_nothing() {
500        let mut app = App::new(vec!["/test".to_string()]);
501        app.recent_loading = false;
502        app.recent_sessions = vec![];
503
504        app.on_enter();
505
506        assert!(!app.should_quit);
507        assert!(app.resume_id.is_none());
508    }
509
510    #[test]
511    fn test_typing_exits_recent_sessions_mode() {
512        let mut app = App::new(vec!["/test".to_string()]);
513        app.recent_loading = false;
514        app.recent_sessions = vec![make_recent_session("s1", "proj-a", "first")];
515        app.recent_cursor = 0;
516
517        // Type a character — should switch to search mode
518        app.on_key('h');
519        assert!(!app.in_recent_sessions_mode());
520        assert_eq!(app.input, "h");
521    }
522
523    #[test]
524    fn test_recent_cursor_preserved_on_clear_input() {
525        let mut app = App::new(vec!["/test".to_string()]);
526        app.recent_loading = false;
527        app.recent_sessions = vec![
528            make_recent_session("s1", "proj-a", "first"),
529            make_recent_session("s2", "proj-b", "second"),
530        ];
531
532        // Navigate down, type something, then clear
533        app.on_down();
534        assert_eq!(app.recent_cursor, 1);
535
536        app.on_key('x');
537        app.clear_input();
538
539        // Back in recent sessions mode — cursor preserved (not reset by clear_input)
540        assert!(app.in_recent_sessions_mode());
541        assert_eq!(app.recent_cursor, 1);
542    }
543}