1use crate::recent::{collect_recent_sessions, detect_session_automation, RecentSession};
2use crate::resume::encode_path_for_claude;
3use crate::search::{
4 group_by_session, search_multiple_paths, RipgrepMatch, SessionGroup, SessionSource,
5};
6use crate::tree::SessionTree;
7use std::collections::{HashMap, HashSet};
8use std::path::Path;
9use std::sync::mpsc::{self, Receiver, Sender};
10use std::thread;
11use std::time::{Duration, Instant};
12
13pub(crate) const DEBOUNCE_MS: u64 = 300;
14const RECENT_SESSIONS_LIMIT: usize = 100;
15
16fn normalize_path_for_prefix_check(path: &str) -> String {
17 let normalized = path.replace('\\', "/");
18 normalized
19 .trim_end_matches(|c| c == '/' || c == '\\')
20 .to_string()
21}
22
23fn path_is_within_project(file_path: &str, project_path: &str) -> bool {
24 let file_path = normalize_path_for_prefix_check(file_path);
25 let project_path = normalize_path_for_prefix_check(project_path);
26
27 file_path == project_path
28 || file_path
29 .strip_prefix(&project_path)
30 .is_some_and(|rest| rest.starts_with('/'))
31}
32
33fn apply_recent_automation_to_groups(
34 groups: &mut [SessionGroup],
35 recent_sessions: &[RecentSession],
36 automation_cache: &mut HashMap<String, Option<String>>,
37) {
38 let mut automation_by_session_id: HashMap<&str, String> = HashMap::new();
39 for session in recent_sessions {
40 if let Some(automation) = &session.automation {
41 automation_by_session_id
42 .entry(session.session_id.as_str())
43 .or_insert_with(|| automation.clone());
44 }
45 automation_cache
46 .entry(session.file_path.clone())
47 .or_insert_with(|| session.automation.clone());
48 }
49
50 for group in groups {
51 if group.automation.is_some() {
52 automation_cache.insert(group.file_path.clone(), group.automation.clone());
53 continue;
54 }
55
56 if let Some(automation) = automation_by_session_id
57 .get(group.session_id.as_str())
58 .cloned()
59 {
60 automation_cache.insert(group.file_path.clone(), Some(automation.clone()));
61 group.automation = Some(automation);
62 continue;
63 }
64
65 if let Some(cached) = automation_cache.get(&group.file_path) {
66 group.automation = cached.clone();
67 continue;
68 }
69
70 let detected = detect_session_automation(Path::new(&group.file_path));
71 automation_cache.insert(group.file_path.clone(), detected.clone());
72 group.automation = detected;
73 }
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum AutomationFilter {
79 All,
81 Manual,
83 Auto,
85}
86
87pub(crate) type SearchResult = (
90 u64,
91 String,
92 Vec<String>,
93 bool,
94 Result<Vec<RipgrepMatch>, String>,
95);
96
97pub struct App {
98 pub input: String,
99 pub results: Vec<RipgrepMatch>,
100 pub(crate) all_groups: Vec<SessionGroup>,
102 pub groups: Vec<SessionGroup>,
104 pub group_cursor: usize,
105 pub sub_cursor: usize,
106 pub expanded: bool,
107 pub searching: bool,
108 pub typing: bool,
109 pub error: Option<String>,
110 pub search_paths: Vec<String>,
111 pub last_query: String,
112 pub results_query: String,
113 pub last_keystroke: Option<Instant>,
114 pub preview_mode: bool,
115 pub should_quit: bool,
116 pub resume_id: Option<String>,
117 pub resume_file_path: Option<String>,
118 pub resume_source: Option<SessionSource>,
120 pub resume_uuid: Option<String>,
122 pub needs_full_redraw: bool,
124 pub regex_mode: bool,
126 pub(crate) last_regex_mode: bool,
128 pub(crate) last_search_paths: Vec<String>,
130 pub(crate) search_rx: Receiver<SearchResult>,
132 pub(crate) search_tx: Sender<(u64, String, Vec<String>, bool)>,
134 pub(crate) search_seq: u64,
136 pub latest_chains: HashMap<String, HashSet<String>>,
138 pub tree_mode: bool,
140 pub session_tree: Option<SessionTree>,
142 pub tree_cursor: usize,
144 pub tree_scroll_offset: usize,
146 pub tree_loading: bool,
148 pub(crate) tree_load_rx: Option<Receiver<Result<SessionTree, String>>>,
150 pub tree_mode_standalone: bool,
152 pub cursor_pos: usize,
154 pub project_filter: bool,
156 pub automation_filter: AutomationFilter,
158 automation_cache: HashMap<String, Option<String>>,
160 pub(crate) all_search_paths: Vec<String>,
162 pub current_project_paths: Vec<String>,
164 pub(crate) all_recent_sessions: Vec<RecentSession>,
166 pub recent_sessions: Vec<RecentSession>,
168 pub recent_cursor: usize,
170 pub recent_scroll_offset: usize,
172 pub recent_loading: bool,
174 pub(crate) recent_load_rx: Option<Receiver<Vec<RecentSession>>>,
176}
177
178impl App {
179 pub fn new(search_paths: Vec<String>) -> Self {
180 let (result_tx, result_rx) = mpsc::channel::<SearchResult>();
182 let (query_tx, query_rx) = mpsc::channel::<(u64, String, Vec<String>, bool)>();
183
184 thread::spawn(move || {
186 while let Ok((seq, query, paths, use_regex)) = query_rx.recv() {
187 let result = search_multiple_paths(&query, &paths, use_regex);
188 let result = (seq, query, paths, use_regex, result);
189 let _ = result_tx.send(result);
190 }
191 });
192
193 let current_project_paths = std::env::current_dir()
195 .ok()
196 .and_then(|cwd| cwd.to_str().map(encode_path_for_claude))
197 .map(|encoded| {
198 search_paths
199 .iter()
200 .filter_map(|base| {
201 let candidate = format!("{}/{}", base, encoded);
202 if std::path::Path::new(&candidate).is_dir() {
203 Some(candidate)
204 } else {
205 None
206 }
207 })
208 .collect::<Vec<_>>()
209 })
210 .unwrap_or_default();
211
212 let all_search_paths = search_paths.clone();
213
214 let (recent_tx, recent_rx) = mpsc::channel::<Vec<RecentSession>>();
216 let recent_paths = search_paths.clone();
217 thread::spawn(move || {
218 let sessions = collect_recent_sessions(&recent_paths, RECENT_SESSIONS_LIMIT);
219 let _ = recent_tx.send(sessions);
220 });
221
222 Self {
223 input: String::new(),
224 results: vec![],
225 all_groups: vec![],
226 groups: vec![],
227 group_cursor: 0,
228 sub_cursor: 0,
229 expanded: false,
230 searching: false,
231 typing: false,
232 error: None,
233 search_paths,
234 last_query: String::new(),
235 results_query: String::new(),
236 last_keystroke: None,
237 preview_mode: false,
238 should_quit: false,
239 resume_id: None,
240 resume_file_path: None,
241 resume_source: None,
242 resume_uuid: None,
243 needs_full_redraw: false,
244 regex_mode: false,
245 last_regex_mode: false,
246 last_search_paths: all_search_paths.clone(),
247 search_rx: result_rx,
248 search_tx: query_tx,
249 search_seq: 0,
250 latest_chains: HashMap::new(),
251 tree_mode: false,
252 session_tree: None,
253 tree_cursor: 0,
254 tree_scroll_offset: 0,
255 tree_loading: false,
256 tree_load_rx: None,
257 tree_mode_standalone: false,
258 cursor_pos: 0,
259 project_filter: false,
260 automation_filter: AutomationFilter::All,
261 automation_cache: HashMap::new(),
262 all_search_paths,
263 current_project_paths,
264 all_recent_sessions: Vec::new(),
265 recent_sessions: Vec::new(),
266 recent_cursor: 0,
267 recent_scroll_offset: 0,
268 recent_loading: true,
269 recent_load_rx: Some(recent_rx),
270 }
271 }
272
273 pub fn on_key(&mut self, c: char) {
274 self.input.insert(self.cursor_pos, c);
275 self.cursor_pos += c.len_utf8();
276 self.typing = true;
277 self.last_keystroke = Some(Instant::now());
278 }
279
280 pub fn on_backspace(&mut self) {
281 if self.cursor_pos > 0 {
282 let prev = self.input[..self.cursor_pos]
284 .char_indices()
285 .next_back()
286 .map(|(i, _)| i)
287 .unwrap_or(0);
288 self.input.remove(prev);
289 self.cursor_pos = prev;
290 self.typing = true;
291 self.last_keystroke = Some(Instant::now());
292 }
293 }
294
295 pub fn on_delete(&mut self) {
296 if self.cursor_pos < self.input.len() {
297 self.input.remove(self.cursor_pos);
298 self.typing = true;
299 self.last_keystroke = Some(Instant::now());
300 }
301 }
302
303 fn reset_search_state(&mut self) {
306 self.last_query.clear();
307 self.results.clear();
308 self.all_groups.clear();
309 self.groups.clear();
310 self.results_query.clear();
311 self.group_cursor = 0;
312 self.sub_cursor = 0;
313 self.expanded = false;
314 self.preview_mode = false;
315 self.latest_chains.clear();
316 self.searching = false;
317 self.error = None;
318 }
319
320 pub fn clear_input(&mut self) {
322 self.input.clear();
323 self.cursor_pos = 0;
324 self.typing = false;
325 self.last_keystroke = None;
326 self.reset_search_state();
327 }
328
329 pub fn move_cursor_left(&mut self) {
330 if self.cursor_pos > 0 {
331 self.cursor_pos = self.input[..self.cursor_pos]
332 .char_indices()
333 .next_back()
334 .map(|(i, _)| i)
335 .unwrap_or(0);
336 }
337 }
338
339 pub fn move_cursor_right(&mut self) {
340 if self.cursor_pos < self.input.len() {
341 self.cursor_pos += self.input[self.cursor_pos..]
342 .chars()
343 .next()
344 .map(|c| c.len_utf8())
345 .unwrap_or(0);
346 }
347 }
348
349 pub fn move_cursor_word_left(&mut self) {
350 let bytes = self.input.as_bytes();
351 let mut pos = self.cursor_pos;
352 while pos > 0 && !bytes[pos - 1].is_ascii_alphanumeric() {
354 pos -= 1;
355 }
356 while pos > 0 && bytes[pos - 1].is_ascii_alphanumeric() {
358 pos -= 1;
359 }
360 self.cursor_pos = pos;
361 }
362
363 pub fn move_cursor_word_right(&mut self) {
364 let bytes = self.input.as_bytes();
365 let len = bytes.len();
366 let mut pos = self.cursor_pos;
367 while pos < len && bytes[pos].is_ascii_alphanumeric() {
369 pos += 1;
370 }
371 while pos < len && !bytes[pos].is_ascii_alphanumeric() {
373 pos += 1;
374 }
375 self.cursor_pos = pos;
376 }
377
378 pub fn move_cursor_home(&mut self) {
379 self.cursor_pos = 0;
380 }
381
382 pub fn move_cursor_end(&mut self) {
383 self.cursor_pos = self.input.len();
384 }
385
386 pub fn delete_word_left(&mut self) {
387 if self.cursor_pos == 0 {
388 return;
389 }
390 let old_pos = self.cursor_pos;
391 self.move_cursor_word_left();
392 self.input.drain(self.cursor_pos..old_pos);
393 self.typing = true;
394 self.last_keystroke = Some(Instant::now());
395 }
396
397 pub fn delete_word_right(&mut self) {
398 if self.cursor_pos >= self.input.len() {
399 return;
400 }
401 let old_pos = self.cursor_pos;
402 self.move_cursor_word_right();
403 let new_pos = self.cursor_pos;
404 self.cursor_pos = old_pos;
405 self.input.drain(old_pos..new_pos);
406 self.typing = true;
407 self.last_keystroke = Some(Instant::now());
408 }
409
410 pub fn tick(&mut self) {
411 if let Some(ref rx) = self.recent_load_rx {
413 if let Ok(sessions) = rx.try_recv() {
414 self.all_recent_sessions = sessions;
415 apply_recent_automation_to_groups(
416 &mut self.all_groups,
417 &self.all_recent_sessions,
418 &mut self.automation_cache,
419 );
420 self.apply_groups_filter();
421 self.apply_recent_sessions_filter();
422 self.recent_loading = false;
423 self.recent_load_rx = None;
424 if !self.recent_sessions.is_empty() {
426 self.recent_cursor = self
427 .recent_cursor
428 .min(self.recent_sessions.len().saturating_sub(1));
429 } else {
430 self.recent_cursor = 0;
431 }
432 }
433 }
434
435 if let Some(ref rx) = self.tree_load_rx {
437 if let Ok(result) = rx.try_recv() {
438 match result {
439 Ok(tree) => {
440 self.session_tree = Some(tree);
441 self.tree_loading = false;
442 self.needs_full_redraw = true;
443 }
444 Err(e) => {
445 self.error = Some(format!("Tree load error: {}", e));
446 self.tree_loading = false;
447 self.tree_mode = false;
448 self.needs_full_redraw = true;
449 }
450 }
451 self.tree_load_rx = None;
452 }
453 }
454
455 while let Ok(result) = self.search_rx.try_recv() {
457 self.handle_search_result(result);
458 }
459
460 if let Some(last) = self.last_keystroke {
462 if last.elapsed() >= Duration::from_millis(DEBOUNCE_MS) {
463 self.last_keystroke = None;
464 self.typing = false;
465
466 let query_changed = self.input != self.last_query;
468 let mode_changed = self.regex_mode != self.last_regex_mode;
469 let scope_changed = self.search_paths != self.last_search_paths;
470 if query_changed && self.input.is_empty() {
471 self.reset_search_state();
473 } else if !self.input.is_empty() && (query_changed || mode_changed || scope_changed)
474 {
475 self.start_search();
476 }
477 }
478 }
479 }
480
481 pub(crate) fn handle_search_result(
482 &mut self,
483 (seq, query, paths, use_regex, result): SearchResult,
484 ) {
485 if seq != self.search_seq
487 || query != self.input
488 || use_regex != self.regex_mode
489 || paths != self.search_paths
490 {
491 return;
492 }
493
494 match result {
495 Ok(results) => {
496 self.results_query = query;
497 let mut groups = group_by_session(results.clone());
498 apply_recent_automation_to_groups(
499 &mut groups,
500 &self.all_recent_sessions,
501 &mut self.automation_cache,
502 );
503 self.all_groups = groups;
504 self.apply_groups_filter();
505 self.results = results;
506 self.group_cursor = 0;
507 self.sub_cursor = 0;
508 self.expanded = false;
509 self.error = None;
510 self.latest_chains.clear();
511 self.searching = false;
512 }
513 Err(e) => {
514 self.error = Some(e);
515 self.searching = false;
516 }
517 }
518 }
519
520 pub(crate) fn apply_recent_sessions_filter(&mut self) {
522 let project_filtered: Vec<_> =
523 if self.project_filter && !self.current_project_paths.is_empty() {
524 self.all_recent_sessions
525 .iter()
526 .filter(|s| {
527 self.current_project_paths
528 .iter()
529 .any(|p| path_is_within_project(&s.file_path, p))
530 })
531 .cloned()
532 .collect()
533 } else {
534 self.all_recent_sessions.clone()
535 };
536
537 self.recent_sessions = match self.automation_filter {
538 AutomationFilter::All => project_filtered,
539 AutomationFilter::Manual => project_filtered
540 .into_iter()
541 .filter(|s| s.automation.is_none())
542 .collect(),
543 AutomationFilter::Auto => project_filtered
544 .into_iter()
545 .filter(|s| s.automation.is_some())
546 .collect(),
547 };
548 if self.recent_sessions.is_empty() {
550 self.recent_cursor = 0;
551 } else {
552 self.recent_cursor = self
553 .recent_cursor
554 .min(self.recent_sessions.len().saturating_sub(1));
555 }
556 }
557
558 pub(crate) fn apply_groups_filter(&mut self) {
560 self.groups = match self.automation_filter {
561 AutomationFilter::All => self.all_groups.clone(),
562 AutomationFilter::Manual => self
563 .all_groups
564 .iter()
565 .filter(|g| g.automation.is_none())
566 .cloned()
567 .collect(),
568 AutomationFilter::Auto => self
569 .all_groups
570 .iter()
571 .filter(|g| g.automation.is_some())
572 .cloned()
573 .collect(),
574 };
575 }
576
577 pub(crate) fn start_search(&mut self) {
579 self.search_seq += 1;
580 self.last_query = self.input.clone();
581 self.last_regex_mode = self.regex_mode;
582 self.last_search_paths = self.search_paths.clone();
583 self.searching = true;
584 let _ = self.search_tx.send((
585 self.search_seq,
586 self.input.clone(),
587 self.search_paths.clone(),
588 self.regex_mode,
589 ));
590 }
591}
592
593#[cfg(test)]
594mod tests {
595 use super::*;
596 use crate::search::Message;
597 use chrono::Utc;
598 use std::io::Write;
599 use tempfile::NamedTempFile;
600
601 fn make_recent_session(file_path: &str) -> RecentSession {
602 RecentSession {
603 session_id: file_path.to_string(),
604 file_path: file_path.to_string(),
605 project: "proj".to_string(),
606 source: SessionSource::ClaudeCodeCLI,
607 timestamp: Utc::now(),
608 summary: "summary".to_string(),
609 automation: None,
610 }
611 }
612
613 #[test]
614 fn test_app_new() {
615 let app = App::new(vec!["/test/path".to_string()]);
616
617 assert_eq!(app.search_paths, vec!["/test/path".to_string()]);
618 assert!(app.input.is_empty());
619 assert!(app.groups.is_empty());
620 assert!(!app.should_quit);
621 }
622
623 #[test]
624 fn test_app_initializes_with_empty_recent_sessions() {
625 let app = App::new(vec!["/nonexistent/path".to_string()]);
626 assert!(app.recent_sessions.is_empty());
627 assert_eq!(app.recent_cursor, 0);
628 assert!(app.recent_loading);
629 assert!(app.recent_load_rx.is_some());
630 }
631
632 #[test]
633 fn test_app_receives_recent_sessions_from_background() {
634 let dir = tempfile::TempDir::new().unwrap();
636 let proj_dir = dir.path().join("-Users-user-proj");
637 std::fs::create_dir_all(&proj_dir).unwrap();
638 let session_file = proj_dir.join("sess1.jsonl");
639 std::fs::write(
640 &session_file,
641 r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"hello world"}]},"sessionId":"sess-1","timestamp":"2025-06-01T10:00:00Z"}"#,
642 )
643 .unwrap();
644
645 let mut app = App::new(vec![dir.path().to_str().unwrap().to_string()]);
646
647 let start = Instant::now();
649 while app.recent_loading && start.elapsed() < Duration::from_secs(5) {
650 app.tick();
651 std::thread::sleep(Duration::from_millis(10));
652 }
653
654 assert!(!app.recent_loading);
655 assert!(app.recent_load_rx.is_none());
656 assert_eq!(app.recent_sessions.len(), 1);
657 assert_eq!(app.recent_sessions[0].session_id, "sess-1");
658 assert_eq!(app.recent_sessions[0].summary, "hello world");
659 }
660
661 #[test]
662 fn test_apply_recent_sessions_filter_matches_mixed_separators() {
663 let mut app = App::new(vec!["/test".to_string()]);
664 app.project_filter = true;
665 app.current_project_paths = vec![r"C:/Users/test/project".to_string()];
666 app.all_recent_sessions = vec![
667 make_recent_session(r"C:\Users\test\project\session.jsonl"),
668 make_recent_session(r"C:\Users\test\project-other\session.jsonl"),
669 ];
670
671 app.apply_recent_sessions_filter();
672
673 assert_eq!(app.recent_sessions.len(), 1);
674 assert_eq!(
675 app.recent_sessions[0].file_path,
676 r"C:\Users\test\project\session.jsonl"
677 );
678 }
679
680 #[test]
681 fn test_handle_search_result_reuses_recent_session_automation() {
682 let mut app = App::new(vec!["/test".to_string()]);
683 app.input = "later".to_string();
684 app.search_seq = 1;
685 app.all_recent_sessions = vec![RecentSession {
686 session_id: "auto-session".to_string(),
687 file_path: "/sessions/auto-session.jsonl".to_string(),
688 project: "proj".to_string(),
689 source: SessionSource::ClaudeCodeCLI,
690 timestamp: Utc::now(),
691 summary: "summary".to_string(),
692 automation: Some("ralphex".to_string()),
693 }];
694
695 let result = RipgrepMatch {
696 file_path: "/sessions/agent-123.jsonl".to_string(),
697 message: Some(Message {
698 session_id: "auto-session".to_string(),
699 role: "assistant".to_string(),
700 content: "Later answer".to_string(),
701 timestamp: Utc::now(),
702 branch: None,
703 line_number: 1,
704 uuid: None,
705 parent_uuid: None,
706 }),
707 source: SessionSource::ClaudeCodeCLI,
708 };
709
710 app.handle_search_result((
711 1,
712 "later".to_string(),
713 app.search_paths.clone(),
714 false,
715 Ok(vec![result]),
716 ));
717
718 assert_eq!(app.all_groups.len(), 1);
719 assert_eq!(app.all_groups[0].automation, Some("ralphex".to_string()));
720 }
721
722 #[test]
723 fn test_handle_search_result_detects_automation_outside_recent_sessions() {
724 let mut session_file = NamedTempFile::new().unwrap();
725 writeln!(session_file, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"<<<RALPHEX: run automation >>>"}}]}},"sessionId":"old-auto","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
726 writeln!(session_file, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Automation reply"}}]}},"sessionId":"old-auto","timestamp":"2025-06-01T10:01:00Z"}}"#).unwrap();
727
728 let mut app = App::new(vec!["/test".to_string()]);
729 app.input = "reply".to_string();
730 app.search_seq = 1;
731 app.automation_filter = AutomationFilter::Auto;
732
733 let result = RipgrepMatch {
734 file_path: session_file.path().to_string_lossy().to_string(),
735 message: Some(Message {
736 session_id: "old-auto".to_string(),
737 role: "assistant".to_string(),
738 content: "Automation reply".to_string(),
739 timestamp: Utc::now(),
740 branch: None,
741 line_number: 2,
742 uuid: None,
743 parent_uuid: None,
744 }),
745 source: SessionSource::ClaudeCodeCLI,
746 };
747
748 app.handle_search_result((
749 1,
750 "reply".to_string(),
751 app.search_paths.clone(),
752 false,
753 Ok(vec![result]),
754 ));
755
756 assert_eq!(app.all_groups.len(), 1);
757 assert_eq!(app.all_groups[0].automation, Some("ralphex".to_string()));
758 assert_eq!(app.groups.len(), 1);
759 }
760
761 #[test]
762 fn test_path_is_within_project_rejects_sibling_prefixes() {
763 assert!(path_is_within_project(
764 r"C:\Users\test\project\session.jsonl",
765 r"C:/Users/test/project"
766 ));
767 assert!(!path_is_within_project(
768 r"C:\Users\test\project-other\session.jsonl",
769 r"C:/Users/test/project"
770 ));
771 }
772
773 #[test]
774 fn test_on_key() {
775 let mut app = App::new(vec!["/test".to_string()]);
776
777 app.on_key('h');
778 app.on_key('e');
779 app.on_key('l');
780 app.on_key('l');
781 app.on_key('o');
782
783 assert_eq!(app.input, "hello");
784 assert!(app.typing);
785 }
786
787 #[test]
788 fn test_on_backspace() {
789 let mut app = App::new(vec!["/test".to_string()]);
790 app.input = "hello".to_string();
791 app.cursor_pos = 5; app.on_backspace();
794
795 assert_eq!(app.input, "hell");
796 assert_eq!(app.cursor_pos, 4);
797 }
798
799 #[test]
800 fn test_clear_input_resets_state() {
801 let mut app = App::new(vec!["/test".to_string()]);
802
803 app.input = "hello".to_string();
805 app.cursor_pos = 5;
806 app.last_query = "hello".to_string();
807 app.results_query = "hello".to_string();
808 app.results = vec![RipgrepMatch {
809 file_path: "/test/file.jsonl".to_string(),
810 message: None,
811 source: SessionSource::ClaudeCodeCLI,
812 }];
813 app.groups = vec![SessionGroup {
814 session_id: "abc123".to_string(),
815 file_path: "/test/file.jsonl".to_string(),
816 matches: vec![],
817 automation: None,
818 }];
819 app.group_cursor = 1;
820 app.sub_cursor = 2;
821 app.expanded = true;
822 app.searching = true;
823 app.typing = true;
824 app.last_keystroke = Some(Instant::now());
825 app.latest_chains.insert("file".to_string(), HashSet::new());
826 app.error = Some("stale error".to_string());
827 app.preview_mode = true;
828
829 app.clear_input();
830
831 assert!(app.input.is_empty(), "input should be cleared");
832 assert!(!app.typing, "typing should be false");
833 assert!(
834 app.last_keystroke.is_none(),
835 "last_keystroke should be None"
836 );
837 assert!(!app.searching, "searching should be false");
838 assert!(app.last_query.is_empty(), "last_query should be cleared");
839 assert!(app.results.is_empty(), "results should be cleared");
840 assert!(app.groups.is_empty(), "groups should be cleared");
841 assert!(
842 app.results_query.is_empty(),
843 "results_query should be cleared"
844 );
845 assert_eq!(app.group_cursor, 0, "group_cursor should be reset");
846 assert_eq!(app.sub_cursor, 0, "sub_cursor should be reset");
847 assert!(!app.expanded, "expanded should be reset");
848 assert!(
849 app.latest_chains.is_empty(),
850 "latest_chains should be cleared"
851 );
852 assert!(app.error.is_none(), "error should be cleared");
853 assert!(!app.preview_mode, "preview_mode should be reset");
854 }
855
856 #[test]
857 fn test_ctrl_c_empty_input_should_quit() {
858 let mut app = App::new(vec!["/test".to_string()]);
859
860 assert!(app.input.is_empty());
862 assert!(!app.should_quit);
863
864 if app.input.is_empty() {
866 app.should_quit = true;
867 } else {
868 app.clear_input();
869 }
870
871 assert!(app.should_quit);
872 }
873
874 #[test]
875 fn test_ctrl_c_with_input_clears_not_quits() {
876 let mut app = App::new(vec!["/test".to_string()]);
877
878 app.on_key('t');
879 app.on_key('e');
880 app.on_key('s');
881 app.on_key('t');
882
883 if app.input.is_empty() {
885 app.should_quit = true;
886 } else {
887 app.clear_input();
888 }
889
890 assert!(app.input.is_empty());
891 assert!(!app.should_quit);
892 }
893
894 #[test]
895 fn test_on_key_inserts_at_cursor() {
896 let mut app = App::new(vec!["/test".to_string()]);
897 app.on_key('a');
898 app.on_key('c');
899 app.cursor_pos = 1; app.on_key('b');
902 assert_eq!(app.input, "abc");
903 assert_eq!(app.cursor_pos, 2);
904 }
905
906 #[test]
907 fn test_on_backspace_at_cursor() {
908 let mut app = App::new(vec!["/test".to_string()]);
909 app.input = "abc".to_string();
910 app.cursor_pos = 2; app.on_backspace();
912 assert_eq!(app.input, "ac");
913 assert_eq!(app.cursor_pos, 1);
914 }
915
916 #[test]
917 fn test_on_backspace_at_start_does_nothing() {
918 let mut app = App::new(vec!["/test".to_string()]);
919 app.input = "abc".to_string();
920 app.cursor_pos = 0;
921 app.on_backspace();
922 assert_eq!(app.input, "abc");
923 assert_eq!(app.cursor_pos, 0);
924 }
925
926 #[test]
927 fn test_on_delete_at_cursor() {
928 let mut app = App::new(vec!["/test".to_string()]);
929 app.input = "abc".to_string();
930 app.cursor_pos = 1; app.on_delete();
932 assert_eq!(app.input, "ac");
933 assert_eq!(app.cursor_pos, 1);
934 }
935
936 #[test]
937 fn test_move_cursor_word_left() {
938 let mut app = App::new(vec!["/test".to_string()]);
939 app.input = "hello world foo".to_string();
940 app.cursor_pos = app.input.len(); app.move_cursor_word_left();
943 assert_eq!(app.cursor_pos, 12); app.move_cursor_word_left();
946 assert_eq!(app.cursor_pos, 6); app.move_cursor_word_left();
949 assert_eq!(app.cursor_pos, 0); app.move_cursor_word_left();
953 assert_eq!(app.cursor_pos, 0);
954 }
955
956 #[test]
957 fn test_move_cursor_word_right() {
958 let mut app = App::new(vec!["/test".to_string()]);
959 app.input = "hello world foo".to_string();
960 app.cursor_pos = 0;
961
962 app.move_cursor_word_right();
963 assert_eq!(app.cursor_pos, 6); app.move_cursor_word_right();
966 assert_eq!(app.cursor_pos, 12); app.move_cursor_word_right();
969 assert_eq!(app.cursor_pos, 15); app.move_cursor_word_right();
973 assert_eq!(app.cursor_pos, 15);
974 }
975
976 #[test]
977 fn test_delete_word_left() {
978 let mut app = App::new(vec!["/test".to_string()]);
979 app.input = "hello world".to_string();
980 app.cursor_pos = app.input.len();
981
982 app.delete_word_left();
983 assert_eq!(app.input, "hello ");
984 assert_eq!(app.cursor_pos, 6);
985
986 app.delete_word_left();
987 assert_eq!(app.input, "");
988 assert_eq!(app.cursor_pos, 0);
989 }
990
991 #[test]
992 fn test_move_cursor_home_end() {
993 let mut app = App::new(vec!["/test".to_string()]);
994 app.input = "hello".to_string();
995 app.cursor_pos = 3;
996
997 app.move_cursor_home();
998 assert_eq!(app.cursor_pos, 0);
999
1000 app.move_cursor_end();
1001 assert_eq!(app.cursor_pos, 5);
1002 }
1003
1004 #[test]
1005 fn test_cursor_bounds_empty_input() {
1006 let mut app = App::new(vec!["/test".to_string()]);
1007
1008 app.move_cursor_left();
1010 app.move_cursor_right();
1011 app.move_cursor_word_left();
1012 app.move_cursor_word_right();
1013 app.move_cursor_home();
1014 app.move_cursor_end();
1015 app.on_backspace();
1016 app.on_delete();
1017 app.delete_word_left();
1018
1019 assert_eq!(app.cursor_pos, 0);
1020 assert!(app.input.is_empty());
1021 }
1022
1023 #[test]
1024 fn test_move_cursor_left_right() {
1025 let mut app = App::new(vec!["/test".to_string()]);
1026 app.input = "abc".to_string();
1027 app.cursor_pos = 3;
1028
1029 app.move_cursor_left();
1030 assert_eq!(app.cursor_pos, 2);
1031
1032 app.move_cursor_left();
1033 assert_eq!(app.cursor_pos, 1);
1034
1035 app.move_cursor_right();
1036 assert_eq!(app.cursor_pos, 2);
1037 }
1038
1039 #[test]
1040 fn test_clear_input_resets_cursor() {
1041 let mut app = App::new(vec!["/test".to_string()]);
1042 app.input = "hello".to_string();
1043 app.cursor_pos = 3;
1044
1045 app.clear_input();
1046
1047 assert_eq!(app.cursor_pos, 0);
1048 assert!(app.input.is_empty());
1049 }
1050
1051 #[test]
1052 fn test_delete_word_right() {
1053 let mut app = App::new(vec!["/test".to_string()]);
1054 app.input = "hello world foo".to_string();
1055 app.cursor_pos = 0;
1056
1057 app.delete_word_right();
1058 assert_eq!(app.input, "world foo");
1059 assert_eq!(app.cursor_pos, 0);
1060
1061 app.delete_word_right();
1062 assert_eq!(app.input, "foo");
1063 assert_eq!(app.cursor_pos, 0);
1064
1065 app.delete_word_right();
1066 assert_eq!(app.input, "");
1067 assert_eq!(app.cursor_pos, 0);
1068 }
1069
1070 #[test]
1071 fn test_delete_word_right_at_end_does_nothing() {
1072 let mut app = App::new(vec!["/test".to_string()]);
1073 app.input = "hello".to_string();
1074 app.cursor_pos = 5;
1075
1076 app.delete_word_right();
1077 assert_eq!(app.input, "hello");
1078 assert_eq!(app.cursor_pos, 5);
1079 }
1080
1081 #[test]
1082 fn test_tick_clears_state_when_query_becomes_empty() {
1083 let mut app = App::new(vec!["/test".to_string()]);
1084
1085 app.input = String::new(); app.last_query = "hello".to_string(); app.results_query = "hello".to_string();
1089 app.results = vec![RipgrepMatch {
1090 file_path: "/test/file.jsonl".to_string(),
1091 message: None,
1092 source: SessionSource::ClaudeCodeCLI,
1093 }];
1094 app.groups = vec![SessionGroup {
1095 session_id: "abc123".to_string(),
1096 file_path: "/test/file.jsonl".to_string(),
1097 matches: vec![],
1098 automation: None,
1099 }];
1100 app.group_cursor = 1;
1101 app.sub_cursor = 2;
1102 app.expanded = true;
1103 app.searching = true;
1104 app.latest_chains.insert("file".to_string(), HashSet::new());
1105 app.error = Some("stale error".to_string());
1106 app.preview_mode = true;
1107
1108 app.last_keystroke = Some(Instant::now() - Duration::from_millis(DEBOUNCE_MS + 50));
1110 app.typing = true;
1111
1112 app.tick();
1113
1114 assert!(
1115 app.results.is_empty(),
1116 "results should be cleared after tick with empty query"
1117 );
1118 assert!(
1119 app.groups.is_empty(),
1120 "groups should be cleared after tick with empty query"
1121 );
1122 assert!(
1123 app.results_query.is_empty(),
1124 "results_query should be cleared after tick with empty query"
1125 );
1126 assert!(
1127 app.last_query.is_empty(),
1128 "last_query should be updated to empty"
1129 );
1130 assert_eq!(app.group_cursor, 0, "group_cursor should be reset");
1131 assert_eq!(app.sub_cursor, 0, "sub_cursor should be reset");
1132 assert!(!app.expanded, "expanded should be reset");
1133 assert!(!app.typing, "typing should be false after debounce");
1134 assert!(!app.searching, "searching should be false");
1135 assert!(
1136 app.latest_chains.is_empty(),
1137 "latest_chains should be cleared"
1138 );
1139 assert!(app.error.is_none(), "error should be cleared");
1140 assert!(!app.preview_mode, "preview_mode should be reset");
1141 }
1142
1143 #[test]
1144 fn test_delete_word_right_from_middle() {
1145 let mut app = App::new(vec!["/test".to_string()]);
1146 app.input = "hello world".to_string();
1147 app.cursor_pos = 5; app.delete_word_right();
1151 assert_eq!(app.input, "helloworld");
1152 assert_eq!(app.cursor_pos, 5);
1153
1154 app.delete_word_right();
1156 assert_eq!(app.input, "hello");
1157 assert_eq!(app.cursor_pos, 5);
1158 }
1159}