Skip to main content

oracle_lib/app/
state.rs

1//! Application state management
2
3use crate::analyzer::{
4    AnalyzedItem, CrateInfo, CrateRegistry, DependencyAnalyzer, InstalledCrate, RustAnalyzer,
5};
6use crate::config::Settings;
7use crate::crates_io::CrateDocInfo;
8use crate::error::Result;
9use crate::ui::theme::Theme;
10use crate::ui::{filter_candidates, CandidateKind, CompletionCandidate, Focus, Tab};
11use crate::utils::dir_size;
12
13use ratatui::widgets::ListState;
14use std::collections::{HashMap, HashSet};
15use std::fmt::Write;
16use std::path::{Path, PathBuf};
17use std::process::Command;
18use std::sync::mpsc;
19use std::thread;
20
21/// Main application state
22pub struct App {
23    // Analysis data
24    pub items: Vec<AnalyzedItem>,
25    pub filtered_items: Vec<usize>,
26    pub crate_info: Option<CrateInfo>,
27    pub dependency_tree: Vec<(String, usize)>,
28    /// Indices into dependency_tree for Crates tab list (filtered by search). Empty = not computed.
29    pub filtered_dependency_indices: Vec<usize>,
30
31    // Installed crates registry
32    pub crate_registry: CrateRegistry,
33    pub installed_crates_list: Vec<String>,
34    pub selected_installed_crate: Option<InstalledCrate>,
35    pub installed_crate_items: Vec<AnalyzedItem>,
36    pub installed_crate_filtered: Vec<usize>,
37
38    // UI state
39    pub search_input: String,
40    pub current_tab: Tab,
41    pub focus: Focus,
42    pub list_state: ListState,
43    pub completion_selected: usize,
44    pub show_completion: bool,
45    pub show_help: bool,
46    pub show_settings: bool,
47    pub status_message: String,
48
49    // Search
50    pub candidates: Vec<CompletionCandidate>,
51    pub filtered_candidates: Vec<CompletionCandidate>,
52
53    // Config
54    pub settings: Settings,
55    pub theme: Theme,
56
57    // Control
58    pub should_quit: bool,
59    pub project_path: Option<PathBuf>,
60
61    // In-TUI Copilot chat (panel to the right of inspector)
62    pub copilot_chat_open: bool,
63    /// (role, content) with role "user" or "assistant"
64    pub copilot_chat_messages: Vec<(String, String)>,
65    pub copilot_chat_input: String,
66    pub copilot_chat_loading: bool,
67    pub copilot_chat_scroll: usize,
68    /// Size of target/ directory in bytes (build artifacts), if computed.
69    pub target_size_bytes: Option<u64>,
70
71    // Dependency tab: fetched docs from crates.io (background thread, bounded cache)
72    pub crate_docs_cache: HashMap<String, CrateDocInfo>,
73    pub crate_docs_loading: Option<String>,
74    pub crate_docs_failed: HashSet<String>,
75    crate_docs_tx: mpsc::Sender<(String, Option<CrateDocInfo>)>,
76    pub crate_docs_rx: mpsc::Receiver<(String, Option<CrateDocInfo>)>,
77
78    pub copilot_tx: mpsc::Sender<String>,
79    pub copilot_rx: mpsc::Receiver<String>,
80}
81
82/// Max crates to keep in docs cache (memory bound).
83const CRATE_DOCS_CACHE_MAX: usize = 50;
84
85impl App {
86    pub fn new() -> Self {
87        let (crate_docs_tx, crate_docs_rx) = mpsc::channel();
88        let (copilot_tx, copilot_rx) = mpsc::channel();
89        Self {
90            items: Vec::new(),
91            filtered_items: Vec::new(),
92            crate_info: None,
93            dependency_tree: Vec::new(),
94            filtered_dependency_indices: Vec::new(),
95            crate_registry: CrateRegistry::new(),
96            installed_crates_list: Vec::new(),
97            selected_installed_crate: None,
98            installed_crate_items: Vec::new(),
99            installed_crate_filtered: Vec::new(),
100            search_input: String::new(),
101            current_tab: Tab::default(),
102            focus: Focus::default(),
103            list_state: ListState::default(),
104            completion_selected: 0,
105            show_completion: false,
106            show_help: false,
107            show_settings: false,
108            status_message: String::from("Ready"),
109            candidates: Vec::new(),
110            filtered_candidates: Vec::new(),
111            settings: Settings::default(),
112            theme: Theme::default(),
113            should_quit: false,
114            project_path: None,
115            target_size_bytes: None,
116            copilot_chat_open: false,
117            copilot_chat_messages: Vec::new(),
118            copilot_chat_input: String::new(),
119            copilot_chat_loading: false,
120            copilot_chat_scroll: 0,
121            crate_docs_cache: HashMap::new(),
122            crate_docs_loading: None,
123            crate_docs_failed: HashSet::new(),
124            crate_docs_tx,
125            crate_docs_rx,
126            copilot_tx,
127            copilot_rx,
128        }
129    }
130
131    /// Load settings from config file
132    pub fn load_settings(&mut self) -> Result<()> {
133        self.settings = Settings::load()?;
134        self.theme = Theme::from_name(&self.settings.ui.theme);
135        Ok(())
136    }
137
138    /// Cycle to the next theme and persist to config
139    pub fn cycle_theme(&mut self) {
140        let next = self.theme.kind().next();
141        self.theme = Theme::from_kind(next);
142        self.settings.ui.theme = next.name().to_string();
143        self.status_message = format!("Theme: {}", next.display_name());
144        let _ = self.settings.save();
145    }
146
147    pub fn toggle_settings(&mut self) {
148        self.show_settings = !self.show_settings;
149    }
150
151    /// Analyze a Rust project
152    pub fn analyze_project(&mut self, path: &Path) -> Result<()> {
153        if !path.exists() {
154            return Err(crate::error::OracleError::Other(format!(
155                "Path does not exist: {}",
156                path.display()
157            )));
158        }
159        self.project_path = Some(path.to_path_buf());
160        self.status_message = format!("Analyzing {}...", path.display());
161
162        // Try to analyze Cargo.toml for dependencies
163        let manifest_path = path.join("Cargo.toml");
164        if manifest_path.exists() {
165            match DependencyAnalyzer::from_manifest(&manifest_path) {
166                Ok(analyzer) => {
167                    if let Some(root) = analyzer.root_package() {
168                        self.dependency_tree = analyzer.dependency_tree(&root.name);
169                        self.crate_info = Some(root);
170                    }
171                }
172                Err(e) => {
173                    self.status_message = format!("Cargo analysis failed: {e}");
174                }
175            }
176        }
177
178        // Analyze Rust source files
179        let analyzer = RustAnalyzer::new().with_private(self.settings.analyzer.include_private);
180
181        let src_path = path.join("src");
182        if path.is_file() && path.extension().is_some_and(|ext| ext == "rs") {
183            self.items = analyzer.analyze_file(path)?;
184        } else if src_path.exists() {
185            self.analyze_directory(&analyzer, &src_path)?;
186        } else if path.is_dir() {
187            // No src/ (e.g. flat layout): analyze directory for .rs files
188            self.analyze_directory(&analyzer, &path.to_path_buf())?;
189        }
190
191        self.update_candidates();
192        self.filter_items();
193        self.status_message = if self.items.is_empty() {
194            format!("No Rust files found in {}", path.display())
195        } else {
196            format!("Found {} items", self.items.len())
197        };
198
199        // Best-effort target/ directory size (non-blocking, ignore errors)
200        let target_dir = path.join("target");
201        if target_dir.is_dir() {
202            self.target_size_bytes = dir_size(&target_dir);
203        } else {
204            self.target_size_bytes = None;
205        }
206
207        Ok(())
208    }
209
210    fn analyze_directory(&mut self, analyzer: &RustAnalyzer, dir: &PathBuf) -> Result<()> {
211        for entry in std::fs::read_dir(dir)? {
212            let entry = entry?;
213            let path = entry.path();
214
215            if path.is_dir() {
216                self.analyze_directory(analyzer, &path)?;
217            } else if path.extension().is_some_and(|ext| ext == "rs") {
218                match analyzer.analyze_file(&path) {
219                    Ok(items) => self.items.extend(items),
220                    Err(e) => {
221                        // Log but continue
222                        eprintln!("Warning: Failed to analyze {}: {}", path.display(), e);
223                    }
224                }
225            }
226        }
227        Ok(())
228    }
229
230    /// Update completion candidates from analyzed items
231    pub fn update_candidates(&mut self) {
232        self.candidates = self
233            .items
234            .iter()
235            .map(|item| {
236                let kind = match item {
237                    AnalyzedItem::Function(_) => CandidateKind::Function,
238                    AnalyzedItem::Struct(_) => CandidateKind::Struct,
239                    AnalyzedItem::Enum(_) => CandidateKind::Enum,
240                    AnalyzedItem::Trait(_) => CandidateKind::Trait,
241                    AnalyzedItem::Module(_) => CandidateKind::Module,
242                    AnalyzedItem::TypeAlias(_) => CandidateKind::Type,
243                    AnalyzedItem::Const(_) | AnalyzedItem::Static(_) => CandidateKind::Const,
244                    _ => CandidateKind::Other,
245                };
246
247                let secondary = item.documentation().map(|d| {
248                    let first_line = d.lines().next().unwrap_or("");
249                    if first_line.len() > 40 {
250                        format!("{}...", &first_line[..37])
251                    } else {
252                        first_line.to_string()
253                    }
254                });
255
256                CompletionCandidate {
257                    primary: item.name().to_string(),
258                    secondary,
259                    kind,
260                    score: 0,
261                }
262            })
263            .collect();
264
265        self.filtered_candidates = self.candidates.clone();
266    }
267
268    /// Filter items based on search input and current tab
269    pub fn filter_items(&mut self) {
270        let query = self.search_input.to_lowercase();
271
272        // Crates tab: when inside a crate, filter its items
273        if self.current_tab == Tab::Crates && self.selected_installed_crate.is_some() {
274            self.filter_installed_crates();
275            return;
276        }
277
278        // Crates tab (top level): filter crate list by name, keep alphabetical order
279        if self.current_tab == Tab::Crates {
280            let mut indices: Vec<usize> = self
281                .dependency_tree
282                .iter()
283                .enumerate()
284                .filter(|(_, (name, _))| {
285                    query.is_empty()
286                        || name.to_lowercase().contains(&query)
287                        || name.to_lowercase().replace('-', "_").contains(&query)
288                })
289                .map(|(i, _)| i)
290                .collect();
291            indices.sort_by(|&a, &b| {
292                self.dependency_tree[a]
293                    .0
294                    .to_lowercase()
295                    .cmp(&self.dependency_tree[b].0.to_lowercase())
296            });
297            self.filtered_dependency_indices = indices;
298            if self
299                .list_state
300                .selected()
301                .is_some_and(|s| s >= self.filtered_dependency_indices.len())
302            {
303                self.list_state.select(Some(0));
304            }
305            self.filtered_candidates = Vec::new();
306            self.completion_selected = 0;
307            return;
308        }
309
310        self.filtered_items = self
311            .items
312            .iter()
313            .enumerate()
314            .filter(|(_, item)| {
315                // Filter by tab
316                let tab_match = match self.current_tab {
317                    Tab::Types => matches!(
318                        item,
319                        AnalyzedItem::Struct(_)
320                            | AnalyzedItem::Enum(_)
321                            | AnalyzedItem::TypeAlias(_)
322                    ),
323                    Tab::Functions => matches!(item, AnalyzedItem::Function(_)),
324                    Tab::Modules => matches!(item, AnalyzedItem::Module(_)),
325                    Tab::Crates => true, // Handled by crate list or filter_installed_crates
326                };
327
328                // Filter by search
329                let search_match = query.is_empty() || item.name().to_lowercase().contains(&query);
330
331                tab_match && search_match
332            })
333            .map(|(i, _)| i)
334            .collect();
335
336        // Reset selection if out of bounds
337        if self
338            .list_state
339            .selected()
340            .is_some_and(|s| s >= self.filtered_items.len())
341        {
342            self.list_state.select(Some(0));
343        }
344
345        // Update completion candidates; only show candidates relevant to the active tab
346        let matched = filter_candidates(&self.candidates, &self.search_input);
347        self.filtered_candidates = match self.current_tab {
348            Tab::Types => matched
349                .into_iter()
350                .filter(|c| {
351                    matches!(
352                        c.kind,
353                        CandidateKind::Struct | CandidateKind::Enum | CandidateKind::Type
354                    )
355                })
356                .collect(),
357            Tab::Functions => matched
358                .into_iter()
359                .filter(|c| c.kind == CandidateKind::Function)
360                .collect(),
361            Tab::Modules => matched
362                .into_iter()
363                .filter(|c| c.kind == CandidateKind::Module)
364                .collect(),
365            Tab::Crates => Vec::new(),
366        };
367        self.completion_selected = 0;
368    }
369
370    /// Scan for installed crates
371    pub fn scan_installed_crates(&mut self) -> Result<()> {
372        self.status_message = "Scanning installed crates...".to_string();
373        self.crate_registry.scan()?;
374        self.installed_crates_list = self
375            .crate_registry
376            .crate_names()
377            .into_iter()
378            .map(|s| s.to_string())
379            .collect();
380        self.status_message = format!(
381            "Found {} installed crates",
382            self.installed_crates_list.len()
383        );
384        Ok(())
385    }
386
387    /// Filter installed crates based on search
388    /// Supports qualified path search like "serde::de::Deserialize"
389    fn filter_installed_crates(&mut self) {
390        let query = self.search_input.to_lowercase();
391
392        if self.selected_installed_crate.is_some() {
393            // Filter items within selected crate by qualified path or name
394            self.installed_crate_filtered = self
395                .installed_crate_items
396                .iter()
397                .enumerate()
398                .filter(|(_, item)| {
399                    if query.is_empty() {
400                        return true;
401                    }
402                    // Check if query contains :: for path matching
403                    if query.contains("::") {
404                        // Match against qualified path
405                        item.qualified_name().to_lowercase().contains(&query) ||
406                        // Or match partial module path
407                        item.module_path().iter()
408                            .any(|p| p.to_lowercase().contains(&query.replace("::", "")))
409                    } else {
410                        // Simple name match
411                        item.name().to_lowercase().contains(&query)
412                    }
413                })
414                .map(|(i, _)| i)
415                .collect();
416        }
417
418        // Reset selection if out of bounds
419        if self
420            .list_state
421            .selected()
422            .is_some_and(|s| s >= self.get_current_list_len())
423        {
424            self.list_state.select(Some(0));
425        }
426    }
427
428    /// Parse qualified path and navigate to crate + filter items
429    /// E.g., "serde::de::Deserialize" -> select serde crate, filter for de::Deserialize
430    pub fn search_qualified_path(&mut self) -> bool {
431        let query = self.search_input.clone();
432        let query = query.trim();
433
434        // Check for qualified path (contains ::)
435        if !query.contains("::") {
436            return false;
437        }
438
439        let parts: Vec<&str> = query.split("::").collect();
440        if parts.is_empty() {
441            return false;
442        }
443
444        let crate_name = parts[0].to_string();
445
446        // Check if crate exists
447        let crate_exists = self.installed_crates_list.iter().any(|name| {
448            name.to_lowercase() == crate_name.to_lowercase()
449                || name.to_lowercase().replace('-', "_") == crate_name.to_lowercase()
450        });
451
452        if !crate_exists {
453            self.status_message = format!("Crate '{}' not found", crate_name);
454            return false;
455        }
456
457        // Find actual crate name (might have hyphens)
458        let actual_name = self
459            .installed_crates_list
460            .iter()
461            .find(|name| {
462                name.to_lowercase() == crate_name.to_lowercase()
463                    || name.to_lowercase().replace('-', "_") == crate_name.to_lowercase()
464            })
465            .cloned();
466
467        // Select the crate if not already selected
468        let already_selected = self
469            .selected_installed_crate
470            .as_ref()
471            .map(|c| c.name.to_lowercase() == crate_name.to_lowercase())
472            .unwrap_or(false);
473
474        if !already_selected {
475            if let Some(name) = actual_name {
476                let _ = self.select_installed_crate(&name);
477            }
478        }
479
480        // Set search to remaining path for filtering
481        if parts.len() > 1 {
482            // Keep the module path part for filtering
483            self.search_input = parts[1..].join("::");
484            self.filter_installed_crates();
485        }
486
487        true
488    }
489
490    /// Select an installed crate and analyze it
491    pub fn select_installed_crate(&mut self, name: &str) -> Result<()> {
492        if let Some(crate_info) = self.crate_registry.latest(name) {
493            self.selected_installed_crate = Some(crate_info.clone());
494            self.status_message = format!("Analyzing {}...", name);
495
496            match self.crate_registry.analyze_crate(name, None) {
497                Ok(items) => {
498                    self.installed_crate_items = items;
499                    self.installed_crate_filtered = (0..self.installed_crate_items.len()).collect();
500                    self.status_message =
501                        format!("{}: {} items", name, self.installed_crate_items.len());
502                }
503                Err(e) => {
504                    self.status_message = format!("Analysis failed: {e}");
505                }
506            }
507        }
508        Ok(())
509    }
510
511    /// Clear selected installed crate (go back to list)
512    pub fn clear_installed_crate(&mut self) {
513        self.selected_installed_crate = None;
514        self.installed_crate_items.clear();
515        self.installed_crate_filtered.clear();
516        self.list_state.select(Some(0));
517    }
518
519    /// Crates to show in Crates tab: project dependencies when we have a Cargo project, else all installed.
520    pub fn installed_crates_display_list(&self) -> Vec<String> {
521        let project_dep_names: HashSet<String> = self
522            .dependency_tree
523            .iter()
524            .filter(|(_, depth)| *depth > 0)
525            .map(|(name, _)| name.clone())
526            .collect();
527        if project_dep_names.is_empty() {
528            self.installed_crates_list.clone()
529        } else {
530            self.installed_crates_list
531                .iter()
532                .filter(|n| project_dep_names.contains(*n))
533                .cloned()
534                .collect()
535        }
536    }
537
538    /// Crate name for "open in browser" (o key): current crate when inside one, or selected dep from list.
539    pub fn selected_crate_name_for_display(&self) -> Option<String> {
540        if self.current_tab != Tab::Crates {
541            return None;
542        }
543        if let Some(ref c) = self.selected_installed_crate {
544            return Some(c.name.clone());
545        }
546        self.selected_dependency_name()
547    }
548
549    /// Selected crate name in Crates tab (root or a dep). None if inside a crate, empty list, or wrong tab.
550    pub fn selected_dependency_name(&self) -> Option<String> {
551        if self.current_tab != Tab::Crates
552            || self.selected_installed_crate.is_some()
553            || self.dependency_tree.is_empty()
554        {
555            return None;
556        }
557        let list_idx = self.list_state.selected().unwrap_or(0);
558        let tree_idx = self.filtered_dependency_indices.get(list_idx).copied()?;
559        self.dependency_tree
560            .get(tree_idx)
561            .map(|(name, _)| name.clone())
562    }
563
564    /// Root crate name in dependency tree (first entry, depth 0). None if no tree.
565    pub fn dependency_root_name(&self) -> Option<&str> {
566        self.dependency_tree.first().map(|(n, _)| n.as_str())
567    }
568
569    /// Process any received crate doc fetch results (call each frame).
570    pub fn poll_crate_docs_rx(&mut self) {
571        while let Ok((name, doc)) = self.crate_docs_rx.try_recv() {
572            if self.crate_docs_loading.as_deref() == Some(name.as_str()) {
573                self.crate_docs_loading = None;
574            }
575            if let Some(info) = doc {
576                if self.crate_docs_cache.len() >= CRATE_DOCS_CACHE_MAX {
577                    if let Some(key) = self.crate_docs_cache.keys().next().cloned() {
578                        self.crate_docs_cache.remove(&key);
579                    }
580                }
581                self.crate_docs_cache.insert(name.clone(), info);
582            } else {
583                self.crate_docs_failed.insert(name);
584            }
585        }
586    }
587
588    /// If on Crates tab and selected crate is not root and not cached/loading/failed, start fetch in background.
589    pub fn maybe_start_crate_doc_fetch(&mut self) {
590        if self.current_tab != Tab::Crates {
591            return;
592        }
593        let Some(name) = self.selected_dependency_name() else {
594            return;
595        };
596        if self.dependency_root_name() == Some(name.as_str()) {
597            return; // selected root: show local crate_info, no fetch
598        }
599        if self.crate_docs_cache.contains_key(&name)
600            || self.crate_docs_loading.as_deref() == Some(name.as_str())
601            || self.crate_docs_failed.contains(&name)
602        {
603            return;
604        }
605        self.crate_docs_loading = Some(name.clone());
606        let tx = self.crate_docs_tx.clone();
607        thread::spawn(move || {
608            let result = crate::crates_io::fetch_crate_docs(&name);
609            let _ = tx.send((name, result));
610        });
611    }
612
613    /// Get current list length based on tab and selection state
614    pub fn get_current_list_len(&self) -> usize {
615        if self.current_tab == Tab::Crates {
616            if self.selected_installed_crate.is_some() {
617                self.installed_crate_filtered.len()
618            } else {
619                let n = self.filtered_dependency_indices.len();
620                if self.dependency_tree.is_empty() || n == 0 {
621                    1
622                } else {
623                    n
624                }
625            }
626        } else {
627            self.filtered_items.len()
628        }
629    }
630
631    /// Get the currently selected item
632    pub fn selected_item(&self) -> Option<&AnalyzedItem> {
633        if self.current_tab == Tab::Crates && self.selected_installed_crate.is_some() {
634            return self
635                .list_state
636                .selected()
637                .and_then(|i| self.installed_crate_filtered.get(i))
638                .and_then(|&idx| self.installed_crate_items.get(idx));
639        }
640        if self.current_tab == Tab::Crates {
641            return None; // Inspector shows root/crate docs, not an item
642        }
643        self.list_state
644            .selected()
645            .and_then(|i| self.filtered_items.get(i))
646            .and_then(|&idx| self.items.get(idx))
647    }
648
649    /// Get filtered items as references
650    pub fn get_filtered_items(&self) -> Vec<&AnalyzedItem> {
651        if self.current_tab == Tab::Crates && self.selected_installed_crate.is_some() {
652            self.installed_crate_filtered
653                .iter()
654                .filter_map(|&i| self.installed_crate_items.get(i))
655                .collect()
656        } else {
657            self.filtered_items
658                .iter()
659                .filter_map(|&i| self.items.get(i))
660                .collect()
661        }
662    }
663
664    // Navigation methods
665    pub fn next_item(&mut self) {
666        let len = self.get_current_list_len();
667        if len == 0 {
668            return;
669        }
670        let i = match self.list_state.selected() {
671            Some(i) => (i + 1) % len,
672            None => 0,
673        };
674        self.list_state.select(Some(i));
675    }
676
677    pub fn prev_item(&mut self) {
678        let len = self.get_current_list_len();
679        if len == 0 {
680            return;
681        }
682        let i = match self.list_state.selected() {
683            Some(i) => i.checked_sub(1).unwrap_or(len - 1),
684            None => 0,
685        };
686        self.list_state.select(Some(i));
687    }
688
689    pub fn next_tab(&mut self) {
690        self.current_tab = self.current_tab.next();
691        self.list_state.select(Some(0));
692        self.show_completion = false; // Hide completions when switching tabs
693        self.filter_items();
694
695        // Scan crates if switching to installed crates tab
696        if self.current_tab == Tab::Crates && self.installed_crates_list.is_empty() {
697            let _ = self.scan_installed_crates();
698        }
699    }
700
701    pub fn prev_tab(&mut self) {
702        self.current_tab = self.current_tab.prev();
703        self.list_state.select(Some(0));
704        self.show_completion = false; // Hide completions when switching tabs
705        self.filter_items();
706
707        if self.current_tab == Tab::Crates && self.installed_crates_list.is_empty() {
708            let _ = self.scan_installed_crates();
709        }
710    }
711
712    pub fn next_focus(&mut self) {
713        self.focus = self.focus.next(self.copilot_chat_open);
714    }
715
716    pub fn prev_focus(&mut self) {
717        self.focus = self.focus.prev(self.copilot_chat_open);
718    }
719
720    pub fn next_completion(&mut self) {
721        if !self.filtered_candidates.is_empty() {
722            self.completion_selected =
723                (self.completion_selected + 1) % self.filtered_candidates.len();
724        }
725    }
726
727    pub fn prev_completion(&mut self) {
728        if !self.filtered_candidates.is_empty() {
729            self.completion_selected = self
730                .completion_selected
731                .checked_sub(1)
732                .unwrap_or(self.filtered_candidates.len() - 1);
733        }
734    }
735
736    pub fn select_completion(&mut self) {
737        if let Some(candidate) = self.filtered_candidates.get(self.completion_selected) {
738            self.search_input = candidate.primary.clone();
739            self.show_completion = false;
740            self.filter_items();
741        }
742    }
743
744    // Input handling
745    pub fn on_char(&mut self, c: char) {
746        self.search_input.push(c);
747        self.filter_items();
748        // Don't show completions in Crates tab - use direct qualified path search
749        self.show_completion = self.search_input.len() >= 2
750            && !(self.current_tab == Tab::Crates && self.selected_installed_crate.is_some());
751    }
752
753    pub fn on_backspace(&mut self) {
754        self.search_input.pop();
755        self.filter_items();
756        self.show_completion = self.search_input.len() >= 2
757            && !(self.current_tab == Tab::Crates && self.selected_installed_crate.is_some());
758    }
759
760    pub fn clear_search(&mut self) {
761        self.search_input.clear();
762        self.show_completion = false;
763        self.filter_items();
764    }
765
766    pub fn toggle_help(&mut self) {
767        self.show_help = !self.show_help;
768    }
769
770    /// Build context string for the currently selected item (for Copilot).
771    pub fn build_copilot_context(&self) -> Option<String> {
772        let item = self.selected_item()?;
773        let loc = item
774            .source_location()
775            .and_then(|l| l.file.as_ref())
776            .map(|p| p.display().to_string())
777            .unwrap_or_else(|| "unknown".to_string());
778        let line = item
779            .source_location()
780            .and_then(|l| l.line)
781            .map(|n| format!(":{}", n))
782            .unwrap_or_default();
783        let mut ctx = format!(
784            "I'm inspecting this Rust item in Oracle TUI. Use it as context.\n\n\
785             **Item:** {} {}\n**Location:** {}{}\n**Definition:**\n```rust\n{}\n```\n",
786            item.kind(),
787            item.qualified_name(),
788            loc,
789            line,
790            item.definition(),
791        );
792        if let Some(doc) = item.documentation() {
793            let doc = doc.lines().take(10).collect::<Vec<_>>().join("\n");
794            ctx.push_str("\n**Docs:**\n");
795            ctx.push_str(&doc);
796            ctx.push('\n');
797        }
798        ctx.push_str("\n---\nAnswer the user's question about this item.");
799        Some(ctx)
800    }
801
802    /// Submit the current chat input to Copilot (spawns thread, sets loading).
803    pub fn submit_copilot_message(&mut self) {
804        let input = self.copilot_chat_input.trim().to_string();
805        if input.is_empty() {
806            return;
807        }
808        self.copilot_chat_input.clear();
809        self.copilot_chat_messages
810            .push(("user".to_string(), input.clone()));
811
812        let context = if let Some(c) = self.build_copilot_context() {
813            c
814        } else {
815            self.copilot_chat_messages
816                .push(("assistant".to_string(), "No item selected.".to_string()));
817            return;
818        };
819
820        let mut full_prompt = context;
821        full_prompt.push_str("\n\n**Conversation:**\n");
822        for (role, content) in &self.copilot_chat_messages {
823            let label = if role == "user" { "User" } else { "Assistant" };
824            let _ = writeln!(full_prompt, "{}: {}", label, content);
825        }
826        full_prompt.push_str("\nRespond to the user's latest message above.");
827
828        let tx = self.copilot_tx.clone();
829        let project_path = self.project_path.clone();
830        thread::spawn(move || {
831            let mut cmd = Command::new("copilot");
832            cmd.arg("-p").arg(&full_prompt).arg("--allow-all").arg("-s");
833            if let Some(ref p) = project_path {
834                cmd.arg("--add-dir").arg(p);
835            }
836            let output = cmd.output();
837            let response = match output {
838                Ok(o) if o.status.success() => {
839                    String::from_utf8_lossy(&o.stdout).trim().to_string()
840                }
841                Ok(o) => format!(
842                    "Copilot error (exit {}): {}",
843                    o.status,
844                    String::from_utf8_lossy(&o.stderr)
845                ),
846                Err(e) => format!("Failed to run copilot: {}", e),
847            };
848            let _ = tx.send(response);
849        });
850        self.copilot_chat_loading = true;
851    }
852
853    /// Toggle Copilot chat panel; when opening with an item selected, focus chat.
854    pub fn toggle_copilot_chat(&mut self) {
855        self.copilot_chat_open = !self.copilot_chat_open;
856        if self.copilot_chat_open && self.selected_item().is_some() {
857            self.focus = Focus::CopilotChat;
858        } else if !self.copilot_chat_open && self.focus == Focus::CopilotChat {
859            self.focus = Focus::Inspector;
860        }
861    }
862}
863
864impl Default for App {
865    fn default() -> Self {
866        Self::new()
867    }
868}
869
870#[cfg(test)]
871mod tests {
872    use super::*;
873    use crate::analyzer::RustAnalyzer;
874
875    fn make_app_with_items() -> App {
876        let source = r#"
877            pub struct Foo {}
878            pub fn bar() {}
879            pub mod baz {}
880        "#;
881        let items = RustAnalyzer::new().analyze_source(source).unwrap();
882        let mut app = App::new();
883        app.items = items;
884        app.filtered_items = vec![0, 1, 2];
885        app.list_state.select(Some(0));
886        app
887    }
888
889    #[test]
890    fn test_get_current_list_len_types_tab() {
891        let mut app = make_app_with_items();
892        app.current_tab = Tab::Types;
893        app.filter_items();
894        assert_eq!(app.get_current_list_len(), 1);
895    }
896
897    #[test]
898    fn test_get_current_list_len_functions_tab() {
899        let mut app = make_app_with_items();
900        app.current_tab = Tab::Functions;
901        app.filter_items();
902        assert_eq!(app.get_current_list_len(), 1);
903    }
904
905    #[test]
906    fn test_get_current_list_len_crates_tab_empty_tree() {
907        let mut app = App::new();
908        app.current_tab = Tab::Crates;
909        app.dependency_tree = vec![];
910        app.filtered_dependency_indices = vec![];
911        assert_eq!(app.get_current_list_len(), 1);
912    }
913
914    #[test]
915    fn test_get_current_list_len_crates_tab_with_deps() {
916        let mut app = App::new();
917        app.current_tab = Tab::Crates;
918        app.dependency_tree = vec![
919            ("oracle".to_string(), 0),
920            ("serde".to_string(), 1),
921            ("ratatui".to_string(), 1),
922        ];
923        app.filtered_dependency_indices = vec![0, 1, 2];
924        assert_eq!(app.get_current_list_len(), 3);
925    }
926
927    #[test]
928    fn test_selected_dependency_name_none_when_wrong_tab() {
929        let mut app = App::new();
930        app.current_tab = Tab::Types;
931        app.dependency_tree = vec![("oracle".to_string(), 0)];
932        app.filtered_dependency_indices = vec![0];
933        app.list_state.select(Some(0));
934        assert!(app.selected_dependency_name().is_none());
935    }
936
937    #[test]
938    fn test_selected_dependency_name_returns_selected() {
939        let mut app = App::new();
940        app.current_tab = Tab::Crates;
941        app.dependency_tree = vec![("oracle".to_string(), 0), ("serde".to_string(), 1)];
942        app.filtered_dependency_indices = vec![0, 1];
943        app.list_state.select(Some(1));
944        assert_eq!(app.selected_dependency_name(), Some("serde".to_string()));
945    }
946
947    #[test]
948    fn test_dependency_root_name() {
949        let mut app = App::new();
950        app.dependency_tree = vec![("oracle".to_string(), 0), ("serde".to_string(), 1)];
951        assert_eq!(app.dependency_root_name(), Some("oracle"));
952        app.dependency_tree.clear();
953        assert!(app.dependency_root_name().is_none());
954    }
955
956    #[test]
957    fn test_selected_item_types_tab() {
958        let mut app = make_app_with_items();
959        app.current_tab = Tab::Types;
960        app.filter_items();
961        app.list_state.select(Some(0));
962        let item = app.selected_item().unwrap();
963        assert_eq!(item.name(), "Foo");
964    }
965
966    #[test]
967    fn test_get_filtered_items() {
968        let mut app = make_app_with_items();
969        app.current_tab = Tab::Types;
970        app.filter_items();
971        let filtered = app.get_filtered_items();
972        assert_eq!(filtered.len(), 1);
973        assert_eq!(filtered[0].name(), "Foo");
974    }
975
976    #[test]
977    fn test_installed_crates_display_list_empty_tree_returns_all_installed() {
978        let mut app = App::new();
979        app.dependency_tree = vec![];
980        app.installed_crates_list = vec!["foo".into(), "bar".into()];
981        let list = app.installed_crates_display_list();
982        assert_eq!(list, vec!["foo", "bar"]);
983    }
984
985    #[test]
986    fn test_installed_crates_display_list_filters_by_project_deps() {
987        let mut app = App::new();
988        app.dependency_tree = vec![("oracle".to_string(), 0), ("serde".to_string(), 1)];
989        app.installed_crates_list = vec!["serde".into(), "other".into()];
990        let list = app.installed_crates_display_list();
991        assert_eq!(list, vec!["serde"]);
992    }
993}