1use crate::colors::DEBOUNCE_MS;
2use crate::commands::{execute_command, show_chunks};
3use crate::config::{PreviewMode, TuiConfig};
4use crate::events::UiEvent;
5use crate::preview::{
6 load_preview_lines, render_chunks_preview, render_heatmap_preview, render_syntax_preview,
7};
8use crate::rendering::{draw_preview, draw_query_input, draw_results_list, draw_status_bar};
9use crate::state::{PreviewCache, TuiState};
10use anyhow::Result;
11use ck_core::{SearchMode, SearchOptions};
12use ck_index::get_index_stats;
13use crossterm::{
14 event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
15 execute,
16 terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
17};
18use ratatui::{
19 Frame, Terminal,
20 backend::{Backend, CrosstermBackend},
21 layout::{Constraint, Direction, Layout},
22 widgets::ListState,
23};
24use shlex::split;
25use std::io;
26use std::path::{Path, PathBuf};
27use std::sync::{Arc, Mutex};
28use std::time::{Duration, Instant};
29use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel};
30use tokio::task::JoinHandle;
31
32pub struct TuiApp {
33 pub state: TuiState,
34 pub list_state: ListState,
35 last_search_time: Instant,
36 search_pending: bool,
37 progress_tx: UnboundedSender<UiEvent>,
38 progress_rx: UnboundedReceiver<UiEvent>,
39 current_generation: u64,
40 active_search: Option<JoinHandle<()>>,
41}
42
43impl TuiApp {
44 pub fn new(search_path: PathBuf, initial_query: Option<String>) -> Self {
45 let query = initial_query.unwrap_or_default();
46 let config = TuiConfig::load();
47 let (progress_tx, progress_rx) = unbounded_channel();
48
49 let mut app = Self {
50 state: TuiState {
51 query: query.clone(),
52 mode: config.search_mode.clone(),
53 results: Vec::new(),
54 selected_idx: 0,
55 preview_content: String::new(),
56 preview_lines: Vec::new(),
57 preview_mode: config.preview_mode.clone(),
58 full_file_mode: config.full_file_mode,
59 scroll_offset: 0,
60 status_message: "Ready. Type to search...".to_string(),
61 search_path,
62 selected_files: Default::default(),
63 search_history: if !query.is_empty() {
64 vec![query]
65 } else {
66 Vec::new()
67 },
68 history_index: 0,
69 command_mode: false,
70 index_stats: None,
71 last_index_stats_refresh: None,
72 index_stats_error: None,
73 preview_cache: None,
74 indexing_message: None,
75 indexing_progress: None,
76 indexing_active: false,
77 indexing_started_at: None,
78 last_indexing_update: None,
79 search_in_progress: false,
80 },
81 list_state: ListState::default(),
82 last_search_time: Instant::now(),
83 search_pending: false,
84 progress_tx,
85 progress_rx,
86 current_generation: 0,
87 active_search: None,
88 };
89 app.list_state.select(Some(0));
90 app
91 }
92
93 pub async fn run(mut self) -> Result<()> {
94 enable_raw_mode()?;
96 let mut stdout = io::stdout();
97 execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
98 let backend = CrosstermBackend::new(stdout);
99 let mut terminal = Terminal::new(backend)?;
100
101 if !self.state.query.is_empty() {
103 self.start_search(&mut terminal)?;
104 self.pump_progress_events();
105 }
106
107 let result = self.event_loop(&mut terminal).await;
109
110 disable_raw_mode()?;
112 execute!(
113 terminal.backend_mut(),
114 LeaveAlternateScreen,
115 DisableMouseCapture
116 )?;
117 terminal.show_cursor()?;
118
119 result
120 }
121
122 async fn event_loop<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> Result<()> {
123 loop {
124 self.pump_progress_events();
125 terminal.draw(|f| self.draw(f))?;
126 self.pump_progress_events();
127
128 if self.search_pending
130 && self.last_search_time.elapsed() >= Duration::from_millis(DEBOUNCE_MS)
131 {
132 self.search_pending = false;
133 self.start_search(terminal)?;
134 self.pump_progress_events();
135 }
136
137 if event::poll(Duration::from_millis(50))?
139 && let Event::Key(key) = event::read()?
140 {
141 if key.kind != KeyEventKind::Press {
143 continue;
144 }
145
146 match key.code {
147 KeyCode::Esc | KeyCode::Char('q') => {
148 return Ok(());
149 }
150 KeyCode::Char('c') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
151 return Ok(());
152 }
153 KeyCode::Char('v') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
154 self.cycle_preview_mode();
156 }
157 KeyCode::Char('f') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
158 self.toggle_full_file_mode();
160 }
161 KeyCode::Char('d') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
162 show_chunks(&mut self.state);
164 }
165 KeyCode::Char(' ') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
166 self.toggle_select();
168 }
169 KeyCode::Tab => {
170 self.cycle_mode();
171 self.trigger_search();
172 }
173 KeyCode::Up if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
174 self.history_previous();
176 }
177 KeyCode::Down if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
178 self.history_next();
180 }
181 KeyCode::Up => {
182 self.previous_result();
183 }
184 KeyCode::Down => {
185 self.next_result();
186 }
187 KeyCode::PageUp => {
188 self.scroll_up();
189 }
190 KeyCode::PageDown => {
191 self.scroll_down();
192 }
193 KeyCode::Enter => {
194 if self.state.command_mode {
196 execute_command(&mut self.state)?;
197 } else {
198 self.open_selected()?;
199 }
200 }
201 KeyCode::Backspace => {
202 self.state.query.pop();
203 if !self.state.query.starts_with('/') {
205 self.state.command_mode = false;
206 }
207 self.trigger_search();
208 }
209 KeyCode::Char(c) => {
210 self.state.query.push(c);
212
213 if self.state.query == "/" {
215 self.state.command_mode = true;
216 }
217
218 self.trigger_search();
219 }
220 _ => {}
221 }
222 self.pump_progress_events();
223 }
224 }
225 }
226
227 fn draw(&mut self, f: &mut Frame) {
228 let chunks = Layout::default()
229 .direction(Direction::Vertical)
230 .constraints([
231 Constraint::Length(3), Constraint::Min(10), Constraint::Length(3), ])
235 .split(f.size());
236
237 draw_query_input(f, chunks[0], &self.state);
239
240 let main_chunks = Layout::default()
242 .direction(Direction::Horizontal)
243 .constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
244 .split(chunks[1]);
245
246 draw_results_list(f, main_chunks[0], &self.state, &mut self.list_state);
248
249 draw_preview(f, main_chunks[1], &self.state);
251
252 self.refresh_index_stats(false);
254 draw_status_bar(f, chunks[2], &self.state);
255 }
256
257 fn save_config(&self) {
258 let config = TuiConfig {
259 search_mode: self.state.mode.clone(),
260 preview_mode: self.state.preview_mode.clone(),
261 full_file_mode: self.state.full_file_mode,
262 };
263 let _ = config.save(); }
265
266 fn cycle_mode(&mut self) {
267 self.state.mode = match self.state.mode {
268 SearchMode::Semantic => SearchMode::Regex,
269 SearchMode::Regex => SearchMode::Hybrid,
270 SearchMode::Hybrid => SearchMode::Semantic,
271 SearchMode::Lexical => SearchMode::Semantic, };
273 self.state.status_message = format!("Switched to {:?} mode", self.state.mode);
274 self.save_config();
275 }
276
277 fn cycle_preview_mode(&mut self) {
278 self.state.preview_mode = match self.state.preview_mode {
279 PreviewMode::Heatmap => PreviewMode::Syntax,
280 PreviewMode::Syntax => PreviewMode::Chunks,
281 PreviewMode::Chunks => PreviewMode::Heatmap,
282 };
283 self.update_preview();
284 self.state.status_message = format!("Preview: {:?}", self.state.preview_mode);
285 self.save_config();
286 }
287
288 fn toggle_full_file_mode(&mut self) {
289 self.state.full_file_mode = !self.state.full_file_mode;
290 self.state.scroll_offset = 0; self.update_preview();
292 let mode_text = if self.state.full_file_mode {
293 "Full File"
294 } else {
295 "Snippet"
296 };
297 self.state.status_message = format!("View: {}", mode_text);
298 self.save_config();
299 }
300
301 fn scroll_up(&mut self) {
302 if self.state.full_file_mode && self.state.scroll_offset > 0 {
303 self.state.scroll_offset = self.state.scroll_offset.saturating_sub(10);
304 self.update_preview();
305 }
306 }
307
308 fn scroll_down(&mut self) {
309 if self.state.full_file_mode {
310 self.state.scroll_offset += 10;
311 self.update_preview();
312 }
313 }
314
315 fn toggle_select(&mut self) {
316 if let Some(result) = self.state.results.get(self.state.selected_idx) {
317 let file = result.file.clone();
318 if self.state.selected_files.contains(&file) {
319 self.state.selected_files.remove(&file);
320 self.state.status_message = format!("Deselected {}", file.display());
321 } else {
322 self.state.selected_files.insert(file.clone());
323 self.state.status_message = format!(
324 "Selected {} ({} total)",
325 file.display(),
326 self.state.selected_files.len()
327 );
328 }
329 }
330 }
331
332 fn history_previous(&mut self) {
333 if self.state.search_history.is_empty() {
334 return;
335 }
336 if self.state.history_index > 0 {
337 self.state.history_index -= 1;
338 self.state.query = self.state.search_history[self.state.history_index].clone();
339 self.trigger_search();
340 }
341 }
342
343 fn history_next(&mut self) {
344 if self.state.history_index < self.state.search_history.len().saturating_sub(1) {
345 self.state.history_index += 1;
346 self.state.query = self.state.search_history[self.state.history_index].clone();
347 self.trigger_search();
348 }
349 }
350
351 fn trigger_search(&mut self) {
352 if self.state.command_mode {
354 return;
355 }
356 self.search_pending = true;
357 self.last_search_time = Instant::now();
358 }
359
360 fn pump_progress_events(&mut self) {
361 while let Ok(event) = self.progress_rx.try_recv() {
362 self.handle_progress_event(event);
363 }
364
365 if let Some(handle) = self.active_search.as_ref()
366 && handle.is_finished()
367 {
368 self.active_search = None;
369 }
370 }
371
372 fn handle_progress_event(&mut self, event: UiEvent) {
373 let current_generation = self.current_generation;
374 match event {
375 UiEvent::Indexing {
376 generation,
377 message,
378 progress,
379 } => {
380 if generation != current_generation {
381 return;
382 }
383 self.state.indexing_active = true;
384 self.state.indexing_message = Some(message);
385 self.state.indexing_progress = progress;
386 let now = Instant::now();
387 if self.state.indexing_started_at.is_none() {
388 self.state.indexing_started_at = Some(now);
389 }
390 self.state.last_indexing_update = Some(now);
391 }
392 UiEvent::IndexingDone { generation } => {
393 if generation != current_generation {
394 return;
395 }
396 self.state.indexing_active = false;
397 self.state.indexing_message = None;
398 self.state.indexing_progress = None;
399 self.state.indexing_started_at = None;
400 self.state.last_indexing_update = None;
401 }
402 UiEvent::SearchProgress {
403 generation,
404 message,
405 } => {
406 if generation != current_generation || !self.state.search_in_progress {
407 return;
408 }
409 self.state.status_message = message;
410 }
411 UiEvent::SearchCompleted {
412 generation,
413 results,
414 summary,
415 query,
416 } => {
417 if generation != current_generation {
418 return;
419 }
420 self.search_pending = false;
421 self.state.search_in_progress = false;
422 self.state.indexing_active = false;
423 self.state.indexing_message = None;
424 self.state.indexing_progress = None;
425 self.state.indexing_started_at = None;
426 self.state.last_indexing_update = None;
427 self.state.selected_files.clear();
428 self.state.results = results;
429 self.state.selected_idx = 0;
430 self.state.scroll_offset = 0;
431 if self.state.results.is_empty() {
432 self.list_state.select(None);
433 } else {
434 self.list_state.select(Some(0));
435 }
436 self.state.preview_cache = None;
437 self.update_preview();
438 self.state.status_message = summary;
439
440 if self.state.search_history.last() != Some(&query) {
441 self.state.search_history.push(query);
442 if self.state.search_history.len() > 20 {
443 self.state.search_history.remove(0);
444 }
445 }
446 if !self.state.search_history.is_empty() {
447 self.state.history_index = self.state.search_history.len() - 1;
448 }
449 }
450 UiEvent::SearchFailed { generation, error } => {
451 if generation != current_generation {
452 return;
453 }
454 self.search_pending = false;
455 self.state.search_in_progress = false;
456 self.state.indexing_active = false;
457 self.state.indexing_message = None;
458 self.state.indexing_progress = None;
459 self.state.indexing_started_at = None;
460 self.state.last_indexing_update = None;
461 self.state.status_message = format!("Search error: {}", error);
462 }
463 }
464 }
465
466 fn refresh_index_stats(&mut self, force: bool) {
467 const REFRESH_INTERVAL: Duration = Duration::from_secs(5);
468 let now = Instant::now();
469 let should_refresh = force
470 || self
471 .state
472 .last_index_stats_refresh
473 .map(|last| now.duration_since(last) >= REFRESH_INTERVAL)
474 .unwrap_or(true);
475
476 if !should_refresh {
477 return;
478 }
479
480 match get_index_stats(&self.state.search_path) {
481 Ok(stats) => {
482 self.state.index_stats = Some(stats);
483 self.state.index_stats_error = None;
484 }
485 Err(err) => {
486 self.state.index_stats = None;
487 self.state.index_stats_error = Some(err.to_string());
488 }
489 }
490
491 self.state.last_index_stats_refresh = Some(now);
492 }
493
494 fn start_search<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> Result<()> {
495 if self.state.query.trim().is_empty() {
496 self.state.results.clear();
497 self.state.preview_content.clear();
498 self.state.preview_lines.clear();
499 self.state.status_message = "Type to search...".to_string();
500 self.state.preview_cache = None;
501 self.state.search_in_progress = false;
502 self.state.indexing_active = false;
503 self.state.indexing_message = None;
504 self.state.indexing_progress = None;
505 self.state.indexing_started_at = None;
506 self.state.last_indexing_update = None;
507 self.list_state.select(None);
508 return Ok(());
509 }
510
511 if let Some(handle) = self.active_search.take() {
513 handle.abort();
514 }
515 self.current_generation = self.current_generation.wrapping_add(1);
516 let generation = self.current_generation;
517
518 self.state.search_in_progress = true;
519 self.state.indexing_active = false;
520 self.state.indexing_message = None;
521 self.state.indexing_progress = None;
522 self.state.indexing_started_at = None;
523 self.state.last_indexing_update = None;
524
525 let mut status_message = "Searching...".to_string();
526 if !matches!(self.state.mode, SearchMode::Regex)
527 && get_index_stats(&self.state.search_path).is_err()
528 {
529 self.state.indexing_active = true;
530 self.state.indexing_message =
531 Some("Indexing repository for semantic search...".to_string());
532 self.state.indexing_started_at = Some(Instant::now());
533 status_message = "Preparing index...".to_string();
534 }
535 self.state.status_message = status_message;
536
537 terminal.draw(|f| self.draw(f))?;
538
539 let threshold = match self.state.mode {
540 SearchMode::Semantic => Some(0.6),
541 SearchMode::Hybrid => None,
542 SearchMode::Regex => None,
543 SearchMode::Lexical => None,
544 };
545
546 let exclude_patterns = ck_core::build_exclude_patterns(
549 &[], true, );
552
553 let options = SearchOptions {
554 mode: self.state.mode.clone(),
555 query: self.state.query.clone(),
556 path: self.state.search_path.clone(),
557 top_k: Some(50),
558 threshold,
559 case_insensitive: false,
560 whole_word: false,
561 fixed_string: false,
562 line_numbers: true,
563 context_lines: 0,
564 before_context_lines: 0,
565 after_context_lines: 0,
566 recursive: true,
567 json_output: false,
568 jsonl_output: false,
569 no_snippet: false,
570 reindex: false,
571 show_scores: true,
572 show_filenames: true,
573 files_with_matches: false,
574 files_without_matches: false,
575 exclude_patterns,
576 include_patterns: Vec::new(),
577 respect_gitignore: true,
578 use_ckignore: true,
579 full_section: false,
580 rerank: false,
581 rerank_model: None,
582 embedding_model: None,
583 };
584
585 let progress_tx = self.progress_tx.clone();
586 let started_at = Instant::now();
587
588 let handle = tokio::spawn(async move {
589 let query_for_history = options.query.clone();
590 let search_progress_sender = progress_tx.clone();
591 let detailed_sender = progress_tx.clone();
592 let completion_sender = progress_tx.clone();
593
594 let search_progress_callback: ck_engine::SearchProgressCallback =
595 Box::new(move |message: &str| {
596 let _ = search_progress_sender.send(UiEvent::SearchProgress {
597 generation,
598 message: message.to_string(),
599 });
600 });
601
602 let throttle = Arc::new(Mutex::new(Instant::now()));
603 let detailed_sender_clone = detailed_sender.clone();
604 let detailed_throttle = throttle.clone();
605 let detailed_indexing_progress_callback: ck_engine::DetailedIndexingProgressCallback =
606 Box::new(move |progress: ck_index::EmbeddingProgress| {
607 let mut last = detailed_throttle.lock().unwrap();
608 if last.elapsed() >= Duration::from_millis(120)
609 || progress.chunk_index + 1 == progress.total_chunks
610 {
611 let total_files = progress.total_files.max(1);
613 let current_file = progress.file_index;
614 let total_chunks_this_file = progress.total_chunks.max(1);
615 let current_chunk = progress.chunk_index + 1;
616
617 let file_progress = current_chunk as f32 / total_chunks_this_file as f32;
619 let overall_pct = ((current_file as f32 + file_progress)
620 / total_files as f32)
621 .clamp(0.0, 1.0);
622
623 let message = format!(
625 "{} • {}/{} files • {}/{} chunks",
626 progress.file_name,
627 current_file + 1,
628 total_files,
629 current_chunk,
630 total_chunks_this_file,
631 );
632 let _ = detailed_sender_clone.send(UiEvent::Indexing {
633 generation,
634 message,
635 progress: Some(overall_pct),
636 });
637 *last = Instant::now();
638 }
639 });
640
641 let result = ck_engine::search_enhanced_with_indexing_progress(
642 &options,
643 Some(search_progress_callback),
644 None, Some(detailed_indexing_progress_callback),
646 )
647 .await;
648
649 match result {
650 Ok(search_results) => {
651 let elapsed_ms = started_at.elapsed().as_millis();
652 let summary = if search_results.matches.is_empty() {
653 format!("No results ({} ms)", elapsed_ms)
654 } else {
655 format!(
656 "Found {} results ({} ms)",
657 search_results.matches.len(),
658 elapsed_ms
659 )
660 };
661 let _ = completion_sender.send(UiEvent::SearchCompleted {
662 generation,
663 results: search_results.matches,
664 summary,
665 query: query_for_history,
666 });
667 }
668 Err(err) => {
669 let _ = completion_sender.send(UiEvent::SearchFailed {
670 generation,
671 error: err.to_string(),
672 });
673 }
674 }
675
676 let _ = detailed_sender.send(UiEvent::IndexingDone { generation });
677 });
678
679 self.active_search = Some(handle);
680
681 Ok(())
682 }
683
684 fn next_result(&mut self) {
685 if self.state.results.is_empty() {
686 return;
687 }
688 self.state.selected_idx = (self.state.selected_idx + 1) % self.state.results.len();
689 self.list_state.select(Some(self.state.selected_idx));
690
691 if self.state.full_file_mode
693 && let Some(result) = self.state.results.get(self.state.selected_idx)
694 {
695 self.state.scroll_offset = result.span.line_start.saturating_sub(6);
697 }
698
699 self.update_preview();
700 }
701
702 fn previous_result(&mut self) {
703 if self.state.results.is_empty() {
704 return;
705 }
706 if self.state.selected_idx == 0 {
707 self.state.selected_idx = self.state.results.len() - 1;
708 } else {
709 self.state.selected_idx -= 1;
710 }
711 self.list_state.select(Some(self.state.selected_idx));
712
713 if self.state.full_file_mode
715 && let Some(result) = self.state.results.get(self.state.selected_idx)
716 {
717 self.state.scroll_offset = result.span.line_start.saturating_sub(6);
719 }
720
721 self.update_preview();
722 }
723
724 fn update_preview(&mut self) {
725 if self.state.results.is_empty() {
727 self.state.preview_content.clear();
728 self.state.preview_lines.clear();
729 return;
730 }
731
732 if let Some(result) = self.state.results.get(self.state.selected_idx) {
733 let cache_miss = self
735 .state
736 .preview_cache
737 .as_ref()
738 .map(|cache| cache.file != result.file)
739 .unwrap_or(true);
740
741 if cache_miss {
742 match load_preview_lines(&result.file) {
743 Ok((lines, is_pdf, chunks)) => {
744 self.state.preview_cache = Some(PreviewCache {
745 file: result.file.clone(),
746 lines,
747 is_pdf,
748 chunks,
749 });
750 }
751 Err(err) => {
752 self.state.preview_content = format!(
753 "File: {}\nScore: {:.3}\n\n{}",
754 result.file.display(),
755 result.score,
756 err
757 );
758 self.state.preview_lines.clear();
759 return;
760 }
761 }
762 }
763
764 let (lines, is_pdf, chunk_spans) = {
765 if let Some(cache) = self.state.preview_cache.as_ref() {
766 (cache.lines.clone(), cache.is_pdf, cache.chunks.clone())
767 } else {
768 self.state.preview_content = format!(
769 "File: {}\nScore: {:.3}\n\n(No preview available)",
770 result.file.display(),
771 result.score
772 );
773 self.state.preview_lines.clear();
774 return;
775 }
776 };
777 let lines_ref = &lines;
778
779 if lines_ref.is_empty() {
781 self.state.preview_content = format!(
782 "File: {}\nScore: {:.3}\n\n(Empty file)",
783 result.file.display(),
784 result.score
785 );
786 self.state.preview_lines.clear();
787 return;
788 }
789
790 let start_line = result
792 .span
793 .line_start
794 .saturating_sub(1)
795 .min(lines_ref.len().saturating_sub(1)); let mut context_start = if self.state.full_file_mode {
797 self.state
798 .scroll_offset
799 .min(lines_ref.len().saturating_sub(1))
800 } else {
801 start_line.saturating_sub(5)
802 };
803 let mut context_end = if self.state.full_file_mode {
804 (context_start + 40).min(lines_ref.len())
805 } else {
806 (start_line + 10).min(lines_ref.len())
807 };
808
809 let chunk_meta = chunk_spans
810 .iter()
811 .filter(|meta| {
812 let span = &meta.span;
813 let line = result.span.line_start;
814 line >= span.line_start && line <= span.line_end
815 })
816 .min_by_key(|meta| meta.span.line_end.saturating_sub(meta.span.line_start))
817 .cloned();
818
819 if self.state.preview_mode == PreviewMode::Chunks
821 && !self.state.full_file_mode
822 && let Some(meta) = chunk_meta.as_ref()
823 {
824 context_start = meta
825 .span
826 .line_start
827 .saturating_sub(1)
828 .min(lines_ref.len().saturating_sub(1));
829 context_end = meta.span.line_end.min(lines_ref.len());
830 }
831
832 if context_end <= context_start {
833 context_end = (context_start + 1).min(lines_ref.len());
834 }
835
836 if context_start >= context_end || context_end > lines_ref.len() {
838 self.state.preview_content = format!(
839 "File: {}\nScore: {:.3}\n\n(Invalid line range)",
840 result.file.display(),
841 result.score
842 );
843 self.state.preview_lines.clear();
844 return;
845 }
846
847 let file_path = result.file.clone();
849 let score = result.score;
850 let match_line = result.span.line_start;
851 let query = self.state.query.clone();
852
853 self.state.preview_lines = match self.state.preview_mode {
854 PreviewMode::Heatmap => render_heatmap_preview(
855 lines_ref,
856 context_start,
857 context_end,
858 &file_path,
859 score,
860 match_line,
861 &query,
862 ),
863 PreviewMode::Syntax => render_syntax_preview(
864 lines_ref,
865 context_start,
866 context_end,
867 &file_path,
868 score,
869 match_line,
870 ),
871 PreviewMode::Chunks => render_chunks_preview(
872 lines_ref,
873 context_start,
874 context_end,
875 &file_path,
876 score,
877 match_line,
878 chunk_meta.as_ref(),
879 is_pdf,
880 &chunk_spans,
881 self.state.full_file_mode,
882 self.state.preview_mode == PreviewMode::Chunks,
883 ),
884 };
885 self.state.preview_content.clear();
886 } else {
887 self.state.preview_content.clear();
888 self.state.preview_lines.clear();
889 }
890 }
891
892 fn open_selected(&self) -> Result<()> {
893 let files_to_open: Vec<(PathBuf, usize)> = if self.state.selected_files.is_empty() {
895 if let Some(result) = self.state.results.get(self.state.selected_idx) {
897 vec![(result.file.clone(), result.span.line_start)]
898 } else {
899 return Ok(());
900 }
901 } else {
902 self.state
904 .selected_files
905 .iter()
906 .filter_map(|file| {
907 self.state
908 .results
909 .iter()
910 .find(|r| &r.file == file)
911 .map(|r| (file.clone(), r.span.line_start))
912 })
913 .collect()
914 };
915
916 if files_to_open.is_empty() {
917 return Ok(());
918 }
919
920 let editor = std::env::var("EDITOR")
921 .or_else(|_| std::env::var("VISUAL"))
922 .unwrap_or_else(|_| "vim".to_string());
923 let editor_parts = split(&editor).unwrap_or_else(|| vec![editor.clone()]);
924 let (command_name, command_args) = match editor_parts.split_first() {
925 Some((command, args)) => (command.to_string(), args.to_vec()),
926 None => (editor.clone(), Vec::new()),
927 };
928
929 disable_raw_mode()?;
931 execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?;
932
933 let mut command = std::process::Command::new(&command_name);
934 command.args(&command_args);
935
936 let editor_basename = Path::new(&command_name)
937 .file_name()
938 .and_then(|n| n.to_str())
939 .unwrap_or(&command_name);
940
941 let status = if editor_basename.contains("cursor") || editor_basename.contains("code") {
943 for (file, line) in &files_to_open {
945 command
946 .arg("-g")
947 .arg(format!("{}:{}", file.display(), line));
948 }
949 command.status()?
950 } else if editor_basename.contains("subl") {
951 for (file, line) in &files_to_open {
953 command.arg(format!("{}:{}", file.display(), line));
954 }
955 command.status()?
956 } else if editor_basename.contains("emacs") {
957 let (file, line) = &files_to_open[0];
959 command
960 .arg(format!("+{}", line))
961 .arg(file.display().to_string())
962 .status()?
963 } else if editor_basename.contains("nano") {
964 let (file, line) = &files_to_open[0];
966 command
967 .arg(format!("+{}", line))
968 .arg(file.display().to_string())
969 .status()?
970 } else {
971 for (file, line) in &files_to_open {
973 command
974 .arg(format!("+{}", line))
975 .arg(file.display().to_string());
976 }
977 if files_to_open.len() > 1 {
978 command.arg("-p"); }
980 command.status()?
981 };
982
983 if !status.success() {
984 eprintln!("Editor exited with error");
985 }
986
987 std::process::exit(0);
989 }
990}