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