1use crate::search::{RipgrepMatch, SessionGroup};
2use crate::tui::App;
3use std::time::Instant;
4
5impl App {
6 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 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 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 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 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 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 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 self.needs_full_redraw = true;
119 }
120 }
121
122 pub fn on_toggle_regex(&mut self) {
123 self.regex_mode = !self.regex_mode;
124 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 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 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 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 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 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 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 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); }
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 #[test]
393 fn test_in_recent_sessions_mode() {
394 let mut app = App::new(vec!["/test".to_string()]);
395 assert!(app.in_recent_sessions_mode());
397
398 app.on_key('h');
400 assert!(!app.in_recent_sessions_mode());
401
402 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 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 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 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 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 assert!(app.recent_loading);
471
472 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 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 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 app.on_down();
534 assert_eq!(app.recent_cursor, 1);
535
536 app.on_key('x');
537 app.clear_input();
538
539 assert!(app.in_recent_sessions_mode());
541 assert_eq!(app.recent_cursor, 1);
542 }
543}