Skip to main content

ck_tui/
app.rs

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        // Setup terminal
95        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        // Run initial search if query provided
102        if !self.state.query.is_empty() {
103            self.start_search(&mut terminal)?;
104            self.pump_progress_events();
105        }
106
107        // Main event loop
108        let result = self.event_loop(&mut terminal).await;
109
110        // Restore terminal
111        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            // Check if we need to trigger a pending search (debouncing)
129            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            // Poll for events with timeout to support debouncing
138            if event::poll(Duration::from_millis(50))?
139                && let Event::Key(key) = event::read()?
140            {
141                // Only process key press events, not release
142                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                        // Ctrl+V: Cycle preview mode
155                        self.cycle_preview_mode();
156                    }
157                    KeyCode::Char('f') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
158                        // Ctrl+F: Toggle snippet/full file
159                        self.toggle_full_file_mode();
160                    }
161                    KeyCode::Char('d') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
162                        // Ctrl+D: Show chunk metadata
163                        show_chunks(&mut self.state);
164                    }
165                    KeyCode::Char(' ') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
166                        // Ctrl+Space: Toggle multi-select
167                        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                        // Ctrl+Up: Navigate search history
175                        self.history_previous();
176                    }
177                    KeyCode::Down if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
178                        // Ctrl+Down: Navigate search history
179                        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                        // In command mode, execute command; otherwise open selected file
195                        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                        // Exit command mode if we backspace the /
204                        if !self.state.query.starts_with('/') {
205                            self.state.command_mode = false;
206                        }
207                        self.trigger_search();
208                    }
209                    KeyCode::Char(c) => {
210                        // All plain characters go to search (including space, s, x, etc.)
211                        self.state.query.push(c);
212
213                        // Enter command mode if / is the first character
214                        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), // Query input
232                Constraint::Min(10),   // Results + Preview
233                Constraint::Length(3), // Status bar
234            ])
235            .split(f.size());
236
237        // Query input box
238        draw_query_input(f, chunks[0], &self.state);
239
240        // Split results and preview
241        let main_chunks = Layout::default()
242            .direction(Direction::Horizontal)
243            .constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
244            .split(chunks[1]);
245
246        // Results list
247        draw_results_list(f, main_chunks[0], &self.state, &mut self.list_state);
248
249        // Preview pane
250        draw_preview(f, main_chunks[1], &self.state);
251
252        // Status bar
253        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(); // Silently ignore errors
264    }
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, // Skip lexical for now
272        };
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; // Reset scroll when toggling
291        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        // Don't trigger search in command mode
353        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        // Cancel any in-flight search task and advance the generation counter.
512        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        // Use the centralized pattern builder from ck-core
547        // Note: .ckignore handling is now done by WalkBuilder hierarchically
548        let exclude_patterns = ck_core::build_exclude_patterns(
549            &[],  // No additional excludes in TUI
550            true, // Use defaults
551        );
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                        // Calculate overall progress across all files
612                        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                        // Overall percentage = (completed files + progress in current file) / total files
618                        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                        // Hierarchical format: filename • files count • chunks count
624                        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, // Skip basic callback - only use detailed callback to avoid flashing
645                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        // In full file mode, reset scroll to show the matched chunk
692        if self.state.full_file_mode
693            && let Some(result) = self.state.results.get(self.state.selected_idx)
694        {
695            // Position scroll so matched line is near the top (but with some context above)
696            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        // In full file mode, reset scroll to show the matched chunk
714        if self.state.full_file_mode
715            && let Some(result) = self.state.results.get(self.state.selected_idx)
716        {
717            // Position scroll so matched line is near the top (but with some context above)
718            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        // Guard against empty results or invalid index
726        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            // Load and cache file content with lines for the preview
734            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            // Ensure we don't have an empty file or invalid line range
780            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            // Calculate context range based on mode
791            let start_line = result
792                .span
793                .line_start
794                .saturating_sub(1)
795                .min(lines_ref.len().saturating_sub(1)); // 0-indexed
796            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            // In Chunks mode + snippet mode, show the full chunk instead of ±5 lines
820            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            // Validate range
837            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            // Render based on preview mode (clone data to avoid borrow issues)
848            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        // Collect files to open (selected files or current result)
894        let files_to_open: Vec<(PathBuf, usize)> = if self.state.selected_files.is_empty() {
895            // No files selected, open current result
896            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            // Open all selected files at their first match line
903            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        // Need to restore terminal before opening editor
930        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        // Open files based on editor type
942        let status = if editor_basename.contains("cursor") || editor_basename.contains("code") {
943            // Cursor/VS Code: can open multiple files with -g
944            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            // Sublime: can open multiple files
952            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            // Emacs: open first file only (multi-file is complex)
958            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            // Nano: open first file only
965            let (file, line) = &files_to_open[0];
966            command
967                .arg(format!("+{}", line))
968                .arg(file.display().to_string())
969                .status()?
970        } else {
971            // Vim/Neovim: can open multiple files with -p (tabs)
972            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"); // Open in tabs
979            }
980            command.status()?
981        };
982
983        if !status.success() {
984            eprintln!("Editor exited with error");
985        }
986
987        // Don't re-enable raw mode - just exit
988        std::process::exit(0);
989    }
990}