Skip to main content

semantic_diff/
app.rs

1use crate::diff::DiffData;
2use crate::grouper::llm::LlmBackend;
3use crate::grouper::{GroupingStatus, SemanticGroup};
4use crate::highlight::HighlightCache;
5use crate::preview::mermaid::{ImageSupport, MermaidCache};
6use crate::review::{GroupReview, ReviewCache, ReviewSection, ReviewSource, SectionState};
7use crate::theme::Theme;
8use crate::ui::file_tree::TreeNodeId;
9use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
10use std::cell::{Cell, RefCell};
11use std::collections::{HashMap, HashSet};
12use tokio::sync::mpsc;
13use tui_tree_widget::TreeState;
14
15/// Hunk-level filter: maps file path → set of hunk indices to show.
16/// An empty set means show all hunks for that file.
17pub type HunkFilter = HashMap<String, HashSet<usize>>;
18
19/// Input mode for the application.
20#[derive(Debug, Clone, PartialEq)]
21pub enum InputMode {
22    Normal,
23    Search,
24    Help,
25    Settings,
26}
27
28/// Which panel currently has keyboard focus.
29#[derive(Debug, Clone, PartialEq)]
30pub enum FocusedPanel {
31    FileTree,
32    DiffView,
33}
34
35/// Messages processed by the TEA update loop.
36#[derive(Debug)]
37pub enum Message {
38    KeyPress(KeyEvent),
39    Resize(u16, u16),
40    RefreshSignal,
41    DebouncedRefresh,
42    DiffParsed(DiffData, String), // parsed data + raw diff for cache hashing
43    GroupingComplete(Vec<SemanticGroup>, u64), // groups + diff_hash for cache saving
44    GroupingFailed(String),
45    IncrementalGroupingComplete(
46        Vec<SemanticGroup>,
47        crate::grouper::DiffDelta,
48        HashMap<String, u64>,
49        u64,    // diff_hash
50        String, // head_commit
51    ),
52    /// A mermaid diagram finished rendering — triggers UI refresh.
53    MermaidReady,
54    ReviewSectionReady(u64, ReviewSection, Result<String, String>),
55}
56
57
58/// Commands returned by update() for the main loop to execute.
59#[allow(dead_code)]
60pub enum Command {
61    SpawnDiffParse { git_diff_args: Vec<String> },
62    SpawnGrouping {
63        backend: LlmBackend,
64        model: String,
65        summaries: String,
66        diff_hash: u64,
67        head_commit: Option<String>,
68        file_hashes: HashMap<String, u64>,
69    },
70    SpawnIncrementalGrouping {
71        backend: LlmBackend,
72        model: String,
73        summaries: String,
74        diff_hash: u64,
75        head_commit: String,
76        file_hashes: HashMap<String, u64>,
77        delta: crate::grouper::DiffDelta,
78    },
79    SpawnReviewSection {
80        backend: crate::grouper::llm::LlmBackend,
81        model: String,
82        section: ReviewSection,
83        prompt: String,
84        group_content_hash: u64,
85    },
86    SpawnReviewBatch(Vec<Command>),
87    CancelReview(u64),
88    Quit,
89}
90
91/// Identifies a collapsible node in the diff tree.
92#[derive(Debug, Clone, Hash, Eq, PartialEq)]
93pub enum NodeId {
94    File(usize),
95    Hunk(usize, usize),
96}
97
98/// UI state for navigation and collapse tracking.
99pub struct UiState {
100    pub selected_index: usize,
101    pub scroll_offset: u16,
102    pub collapsed: HashSet<NodeId>,
103    /// Terminal viewport height, updated each frame.
104    pub viewport_height: u16,
105    /// Width of the diff view panel (Cell for interior mutability during render).
106    pub diff_view_width: Cell<u16>,
107    /// Scroll offset for preview mode (line-based, not item-based).
108    pub preview_scroll: usize,
109}
110
111/// An item in the flattened visible list.
112#[derive(Debug, Clone)]
113pub enum VisibleItem {
114    FileHeader { file_idx: usize },
115    HunkHeader { file_idx: usize, hunk_idx: usize },
116    DiffLine { file_idx: usize, hunk_idx: usize, line_idx: usize },
117}
118
119/// The main application state (TEA Model).
120pub struct App {
121    pub diff_data: DiffData,
122    pub ui_state: UiState,
123    pub highlight_cache: HighlightCache,
124    #[allow(dead_code)]
125    pub should_quit: bool,
126    /// Channel sender for spawning debounce timers that send DebouncedRefresh.
127    pub event_tx: Option<mpsc::Sender<Message>>,
128    /// Handle to the current debounce timer task, if any.
129    pub debounce_handle: Option<tokio::task::JoinHandle<()>>,
130    /// Current input mode (Normal or Search).
131    pub input_mode: InputMode,
132    /// Current search query being typed.
133    pub search_query: String,
134    /// The confirmed filter pattern (set on Enter in search mode).
135    pub active_filter: Option<String>,
136    /// Semantic groups from LLM, if available. None = ungrouped.
137    pub semantic_groups: Option<Vec<SemanticGroup>>,
138    /// Lifecycle state of the current grouping request.
139    pub grouping_status: GroupingStatus,
140    /// Handle to the in-flight grouping task, for cancellation (ROB-05).
141    pub grouping_handle: Option<tokio::task::JoinHandle<()>>,
142    /// Which LLM backend is available (Claude preferred, Copilot fallback), if any.
143    pub llm_backend: Option<LlmBackend>,
144    /// Model string resolved for the active backend.
145    pub llm_model: String,
146    /// Which panel currently has keyboard focus.
147    pub focused_panel: FocusedPanel,
148    /// Persistent tree state for tui-tree-widget (RefCell for interior mutability in render).
149    pub tree_state: RefCell<TreeState<TreeNodeId>>,
150    /// When a group is selected in the sidebar, filter the diff view to those (file, hunk) pairs.
151    /// Key = file path (stripped), Value = set of hunk indices (empty = all hunks).
152    pub tree_filter: Option<HunkFilter>,
153    /// Active theme (colors + syntect theme name), derived from config at startup.
154    pub theme: Theme,
155    /// HEAD commit hash when current groups were computed. Used for incremental grouping.
156    pub previous_head: Option<String>,
157    /// Per-file content hashes from the last grouping. Used to detect what changed.
158    pub previous_file_hashes: HashMap<String, u64>,
159    /// Git diff arguments from the CLI, used for refreshes.
160    pub git_diff_args: Vec<String>,
161    /// Whether markdown preview mode is active (toggled with "p").
162    pub preview_mode: bool,
163    /// Whether the terminal supports inline image rendering.
164    pub image_support: ImageSupport,
165    /// Mermaid diagram cache (only used when image_support == Supported).
166    pub mermaid_cache: Option<MermaidCache>,
167    pub review_cache: ReviewCache,
168    pub review_handles: std::collections::HashMap<(u64, ReviewSection), tokio::task::JoinHandle<()>>,
169    pub active_review_group: Option<u64>,
170    pub review_scroll: usize,
171    pub review_source: ReviewSource,
172}
173
174impl App {
175    /// Create a new App with parsed diff data, user config, and git diff arguments.
176    pub fn new(diff_data: DiffData, config: &crate::config::Config, git_diff_args: Vec<String>) -> Self {
177        let theme = Theme::from_mode(config.theme_mode);
178        let highlight_cache = HighlightCache::new(&diff_data, theme.syntect_theme);
179        let image_support = crate::preview::mermaid::detect_image_support();
180        let mermaid_cache = match &image_support {
181            ImageSupport::Supported(_) => Some(MermaidCache::new()),
182            _ => None,
183        };
184        Self {
185            diff_data,
186            ui_state: UiState {
187                selected_index: 0,
188                scroll_offset: 0,
189                collapsed: HashSet::new(),
190                viewport_height: 24, // will be updated on first draw
191                diff_view_width: Cell::new(80),
192                preview_scroll: 0,
193            },
194            highlight_cache,
195            should_quit: false,
196            event_tx: None,
197            debounce_handle: None,
198            input_mode: InputMode::Normal,
199            search_query: String::new(),
200            active_filter: None,
201            semantic_groups: None,
202            grouping_status: GroupingStatus::Idle,
203            grouping_handle: None,
204            llm_backend: config.detect_backend(),
205            llm_model: config
206                .detect_backend()
207                .map(|b| config.model_for_backend(b).to_string())
208                .unwrap_or_default(),
209            focused_panel: FocusedPanel::DiffView,
210            tree_state: RefCell::new(TreeState::default()),
211            tree_filter: None,
212            theme,
213            previous_head: None,
214            previous_file_hashes: HashMap::new(),
215            git_diff_args,
216            preview_mode: false,
217            image_support,
218            mermaid_cache,
219            review_cache: ReviewCache::new(),
220            review_handles: std::collections::HashMap::new(),
221            active_review_group: None,
222            review_scroll: 0,
223            review_source: crate::review::detect_review_skill(),
224        }
225    }
226
227    /// TEA update: dispatch message to handler, return optional command.
228    pub fn update(&mut self, msg: Message) -> Option<Command> {
229        match msg {
230            Message::KeyPress(key) => self.handle_key(key),
231            Message::Resize(_w, h) => {
232                self.ui_state.viewport_height = h.saturating_sub(1);
233                None
234            }
235            Message::RefreshSignal => {
236                // Cancel any existing debounce timer
237                if let Some(handle) = self.debounce_handle.take() {
238                    handle.abort();
239                }
240                // Spawn a new debounce timer: 500ms delay before refresh
241                if let Some(tx) = &self.event_tx {
242                    let tx = tx.clone();
243                    self.debounce_handle = Some(tokio::spawn(async move {
244                        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
245                        let _ = tx.send(Message::DebouncedRefresh).await;
246                    }));
247                }
248                None
249            }
250            Message::DebouncedRefresh => {
251                self.debounce_handle = None;
252                Some(Command::SpawnDiffParse {
253                    git_diff_args: self.git_diff_args.clone(),
254                })
255            }
256            Message::DiffParsed(new_data, raw_diff) => {
257                self.apply_new_diff_data(new_data);
258                let hash = crate::cache::diff_hash(&raw_diff);
259                let current_head = crate::cache::get_head_commit();
260
261                // Check exact diff hash cache first (handles identical re-triggers)
262                if let Some(cached) = crate::cache::load(hash) {
263                    let mut groups = cached;
264                    crate::grouper::normalize_hunk_indices(&mut groups, &self.diff_data);
265                    self.semantic_groups = Some(groups);
266                    self.grouping_status = GroupingStatus::Done;
267                    self.grouping_handle = None;
268                    // Update incremental state
269                    if let Some(ref head) = current_head {
270                        self.previous_head = Some(head.clone());
271                    }
272                    self.previous_file_hashes =
273                        crate::grouper::compute_all_file_hashes(&self.diff_data);
274                    return None;
275                }
276
277                // Try incremental path: same HEAD + have previous groups
278                let can_incremental = current_head.is_some()
279                    && self.previous_head.as_ref() == current_head.as_ref()
280                    && self.semantic_groups.is_some()
281                    && !self.previous_file_hashes.is_empty();
282
283                if can_incremental {
284                    let new_hashes = crate::grouper::compute_all_file_hashes(&self.diff_data);
285                    let delta =
286                        crate::grouper::compute_diff_delta(&new_hashes, &self.previous_file_hashes);
287
288                    if !delta.has_changes() {
289                        // Nothing changed — keep existing groups
290                        self.grouping_status = GroupingStatus::Done;
291                        return None;
292                    }
293
294                    if delta.is_only_removals() {
295                        // Only files removed — prune groups locally, no LLM needed
296                        let mut groups = self.semantic_groups.clone().unwrap_or_default();
297                        crate::grouper::remove_files_from_groups(&mut groups, &delta.removed_files);
298                        crate::grouper::normalize_hunk_indices(&mut groups, &self.diff_data);
299                        self.semantic_groups = Some(groups);
300                        self.grouping_status = GroupingStatus::Done;
301                        self.previous_file_hashes = new_hashes.clone();
302                        // Save updated cache
303                        if let Some(ref head) = current_head {
304                            crate::cache::save_with_state(
305                                hash,
306                                self.semantic_groups.as_ref().unwrap(),
307                                Some(head),
308                                &new_hashes,
309                            );
310                        }
311                        return None;
312                    }
313
314                    // New or modified files — spawn incremental LLM grouping
315                    if let Some(backend) = self.llm_backend {
316                        if let Some(handle) = self.grouping_handle.take() {
317                            handle.abort();
318                        }
319                        self.grouping_status = GroupingStatus::Loading;
320                        let existing = self.semantic_groups.as_ref().unwrap();
321                        let summaries = crate::grouper::incremental_hunk_summaries(
322                            &self.diff_data,
323                            &delta,
324                            existing,
325                        );
326                        tracing::info!(
327                            new = delta.new_files.len(),
328                            modified = delta.modified_files.len(),
329                            removed = delta.removed_files.len(),
330                            unchanged = delta.unchanged_files.len(),
331                            "Incremental grouping"
332                        );
333                        return Some(Command::SpawnIncrementalGrouping {
334                            backend,
335                            model: self.llm_model.clone(),
336                            summaries,
337                            diff_hash: hash,
338                            head_commit: current_head.unwrap(),
339                            file_hashes: new_hashes,
340                            delta,
341                        });
342                    }
343                }
344
345                // Fallback: full re-group
346                if let Some(backend) = self.llm_backend {
347                    // Cancel in-flight grouping (ROB-05)
348                    if let Some(handle) = self.grouping_handle.take() {
349                        handle.abort();
350                    }
351                    self.grouping_status = GroupingStatus::Loading;
352                    let summaries = crate::grouper::hunk_summaries(&self.diff_data);
353                    let file_hashes = crate::grouper::compute_all_file_hashes(&self.diff_data);
354                    Some(Command::SpawnGrouping {
355                        backend,
356                        model: self.llm_model.clone(),
357                        summaries,
358                        diff_hash: hash,
359                        head_commit: current_head,
360                        file_hashes,
361                    })
362                } else {
363                    self.grouping_status = GroupingStatus::Idle;
364                    None
365                }
366            }
367            Message::GroupingComplete(groups, diff_hash) => {
368                let mut groups = groups;
369                crate::grouper::normalize_hunk_indices(&mut groups, &self.diff_data);
370                // Update incremental state for next refresh
371                let current_head = crate::cache::get_head_commit();
372                let file_hashes = crate::grouper::compute_all_file_hashes(&self.diff_data);
373                // Save with full incremental state
374                crate::cache::save_with_state(
375                    diff_hash,
376                    &groups,
377                    current_head.as_deref(),
378                    &file_hashes,
379                );
380                self.previous_head = current_head;
381                self.previous_file_hashes = file_hashes;
382                self.semantic_groups = Some(groups);
383                self.grouping_status = GroupingStatus::Done;
384                self.grouping_handle = None;
385                // Reset tree state since structure changed from flat→grouped
386                let mut ts = self.tree_state.borrow_mut();
387                *ts = TreeState::default();
388                ts.select_first();
389                drop(ts);
390                // Clear any stale tree filter from the flat view
391                self.tree_filter = None;
392                // Spawn reviews for all groups in parallel
393                self.spawn_all_reviews()
394            }
395            Message::IncrementalGroupingComplete(new_assignments, delta, file_hashes, diff_hash, head_commit) => {
396                let existing = self.semantic_groups.as_ref().cloned().unwrap_or_default();
397                let mut merged =
398                    crate::grouper::merge_groups(&existing, &new_assignments, &delta);
399                crate::grouper::normalize_hunk_indices(&mut merged, &self.diff_data);
400                // Save merged groups to cache with incremental state
401                crate::cache::save_with_state(
402                    diff_hash,
403                    &merged,
404                    Some(&head_commit),
405                    &file_hashes,
406                );
407                self.semantic_groups = Some(merged);
408                self.grouping_status = GroupingStatus::Done;
409                self.grouping_handle = None;
410                self.previous_file_hashes = file_hashes;
411                self.previous_head = Some(head_commit);
412                // Reset tree state since structure changed
413                let mut ts = self.tree_state.borrow_mut();
414                *ts = TreeState::default();
415                ts.select_first();
416                drop(ts);
417                self.tree_filter = None;
418                // Spawn reviews for all groups in parallel
419                self.spawn_all_reviews()
420            }
421            Message::GroupingFailed(err) => {
422                tracing::warn!("Grouping failed: {}", err);
423                self.grouping_status = GroupingStatus::Error(err);
424                self.grouping_handle = None;
425                None // Continue showing ungrouped — graceful degradation (ROB-06)
426            }
427            Message::MermaidReady => None, // just triggers UI refresh
428            Message::ReviewSectionReady(hash, section, result) => {
429                self.review_handles.remove(&(hash, section));
430                if let Some(review) = self.review_cache.get_mut(&hash) {
431                    match result {
432                        Ok(content) => {
433                            if section == ReviewSection::How && content.trim() == "SKIP" {
434                                review.sections.insert(section, SectionState::Skipped);
435                            } else {
436                                review.sections.insert(section, SectionState::Ready(content));
437                            }
438                        }
439                        Err(msg) => {
440                            review.sections.insert(section, SectionState::Error(msg));
441                        }
442                    }
443                    let all_complete = review.sections.values().all(|s| s.is_complete());
444                    if all_complete {
445                        let review_clone = review.clone();
446                        crate::review::save_review_to_disk(&review_clone);
447                    }
448                }
449                None
450            }
451        }
452    }
453
454    /// Apply new diff data while preserving scroll position and collapse state.
455    fn apply_new_diff_data(&mut self, new_data: DiffData) {
456        // 1. Record collapsed state by file path (not index)
457        let mut collapsed_files: HashSet<String> = HashSet::new();
458        let mut collapsed_hunks: HashSet<(String, usize)> = HashSet::new();
459
460        for node in &self.ui_state.collapsed {
461            match node {
462                NodeId::File(fi) => {
463                    if let Some(file) = self.diff_data.files.get(*fi) {
464                        collapsed_files.insert(file.target_file.clone());
465                    }
466                }
467                NodeId::Hunk(fi, hi) => {
468                    if let Some(file) = self.diff_data.files.get(*fi) {
469                        collapsed_hunks.insert((file.target_file.clone(), *hi));
470                    }
471                }
472            }
473        }
474
475        // 2. Record current selected file path for position preservation
476        let selected_path = self.selected_file_path();
477
478        // 3. Replace diff data and rebuild highlight cache
479        self.diff_data = new_data;
480        self.highlight_cache = HighlightCache::new(&self.diff_data, self.theme.syntect_theme);
481
482        // 4. Rebuild collapsed set with new indices
483        self.ui_state.collapsed.clear();
484        for (fi, file) in self.diff_data.files.iter().enumerate() {
485            if collapsed_files.contains(&file.target_file) {
486                self.ui_state.collapsed.insert(NodeId::File(fi));
487            }
488            for (hi, _) in file.hunks.iter().enumerate() {
489                if collapsed_hunks.contains(&(file.target_file.clone(), hi)) {
490                    self.ui_state.collapsed.insert(NodeId::Hunk(fi, hi));
491                }
492            }
493        }
494
495        // 5. Restore selected position by file path, or clamp
496        if let Some(path) = selected_path {
497            let items = self.visible_items();
498            let restored = items.iter().position(|item| {
499                if let VisibleItem::FileHeader { file_idx } = item {
500                    self.diff_data.files[*file_idx].target_file == path
501                } else {
502                    false
503                }
504            });
505            if let Some(idx) = restored {
506                self.ui_state.selected_index = idx;
507            } else {
508                self.ui_state.selected_index = self
509                    .ui_state
510                    .selected_index
511                    .min(items.len().saturating_sub(1));
512            }
513        } else {
514            let items_len = self.visible_items().len();
515            self.ui_state.selected_index = self
516                .ui_state
517                .selected_index
518                .min(items_len.saturating_sub(1));
519        }
520
521        self.adjust_scroll();
522    }
523
524    /// Get the file path of the currently selected item (for position preservation).
525    fn selected_file_path(&self) -> Option<String> {
526        let items = self.visible_items();
527        let item = items.get(self.ui_state.selected_index)?;
528        let fi = match item {
529            VisibleItem::FileHeader { file_idx } => *file_idx,
530            VisibleItem::HunkHeader { file_idx, .. } => *file_idx,
531            VisibleItem::DiffLine { file_idx, .. } => *file_idx,
532        };
533        self.diff_data.files.get(fi).map(|f| f.target_file.clone())
534    }
535
536    /// Handle a key press event, branching on input mode.
537    fn handle_key(&mut self, key: KeyEvent) -> Option<Command> {
538        match self.input_mode {
539            InputMode::Normal => self.handle_key_normal(key),
540            InputMode::Search => self.handle_key_search(key),
541            InputMode::Help => {
542                // Any key closes help
543                self.input_mode = InputMode::Normal;
544                None
545            }
546            InputMode::Settings => self.handle_key_settings(key),
547        }
548    }
549
550    /// Handle keys while the Settings overlay is open.
551    fn handle_key_settings(&mut self, key: KeyEvent) -> Option<Command> {
552        match key.code {
553            KeyCode::Char('d') => {
554                self.toggle_theme();
555                None
556            }
557            KeyCode::Esc => {
558                self.input_mode = InputMode::Normal;
559                None
560            }
561            _ => None,
562        }
563    }
564
565    /// Toggle between dark and light theme, rebuilding the highlight cache.
566    pub fn toggle_theme(&mut self) {
567        let new_theme = if self.theme.syntect_theme.contains("dark") {
568            crate::theme::Theme::light()
569        } else {
570            crate::theme::Theme::dark()
571        };
572        self.theme = new_theme;
573        self.highlight_cache = HighlightCache::new(&self.diff_data, self.theme.syntect_theme);
574    }
575
576    /// Handle keys in Normal mode.
577    fn handle_key_normal(&mut self, key: KeyEvent) -> Option<Command> {
578        // Global keys that work regardless of focused panel
579        match key.code {
580            KeyCode::Char('q') => return Some(Command::Quit),
581            KeyCode::Char('?') => {
582                self.input_mode = InputMode::Help;
583                return None;
584            }
585            KeyCode::Char(',') => {
586                self.input_mode = InputMode::Settings;
587                return None;
588            }
589            KeyCode::Tab => {
590                self.focused_panel = match self.focused_panel {
591                    FocusedPanel::FileTree => FocusedPanel::DiffView,
592                    FocusedPanel::DiffView => FocusedPanel::FileTree,
593                };
594                return None;
595            }
596            KeyCode::Esc => {
597                if self.tree_filter.is_some() || self.active_filter.is_some() {
598                    self.tree_filter = None;
599                    self.active_filter = None;
600                    self.ui_state.selected_index = 0;
601                    self.adjust_scroll();
602                    return None;
603                } else {
604                    return Some(Command::Quit);
605                }
606            }
607            KeyCode::Char('/') => {
608                self.input_mode = InputMode::Search;
609                self.search_query.clear();
610                return None;
611            }
612            KeyCode::Char('p') => {
613                if crate::ui::preview_view::is_current_file_markdown(self) {
614                    self.preview_mode = !self.preview_mode;
615                    if self.preview_mode {
616                        self.ui_state.preview_scroll = 0;
617                    }
618                }
619                return None;
620            }
621            _ => {}
622        }
623
624        // Route to panel-specific handler
625        match self.focused_panel {
626            FocusedPanel::FileTree => self.handle_key_tree(key),
627            FocusedPanel::DiffView => self.handle_key_diff(key),
628        }
629    }
630
631    /// Handle keys when the file tree sidebar is focused.
632    fn handle_key_tree(&mut self, key: KeyEvent) -> Option<Command> {
633        let mut ts = self.tree_state.borrow_mut();
634        match key.code {
635            KeyCode::Char('j') | KeyCode::Down => {
636                ts.key_down();
637            }
638            KeyCode::Char('k') | KeyCode::Up => {
639                ts.key_up();
640            }
641            KeyCode::Left => {
642                ts.key_left();
643            }
644            KeyCode::Right => {
645                ts.key_right();
646            }
647            KeyCode::Enter => {
648                ts.toggle_selected();
649            }
650            KeyCode::Char('g') => {
651                ts.select_first();
652            }
653            KeyCode::Char('G') => {
654                ts.select_last();
655            }
656            _ => return None,
657        }
658
659        // After any navigation, sync the diff view to the selected tree node
660        let selected = ts.selected().to_vec();
661        drop(ts); // release borrow before mutating self
662        self.apply_tree_selection(&selected)
663    }
664
665    /// Update the diff view filter based on the currently selected tree node.
666    fn apply_tree_selection(&mut self, selected: &[TreeNodeId]) -> Option<Command> {
667        match selected.last() {
668            Some(TreeNodeId::File(group_idx, path)) => {
669                self.select_tree_file(path, *group_idx);
670                // Clear review pane display, but let in-flight reviews finish
671                // in the background so results get cached.
672                self.active_review_group = None;
673                self.review_scroll = 0;
674                None
675            }
676            Some(TreeNodeId::Group(gi)) => {
677                self.select_tree_group(*gi);
678                // Set this group as active for the review pane display.
679                // Reviews are pre-spawned for all groups when grouping completes,
680                // so by the time the user navigates here the review is likely
681                // already cached or in-flight.
682                if let Some(groups) = &self.semantic_groups {
683                    if let Some(group) = groups.get(*gi) {
684                        let hash = crate::review::group_content_hash(group);
685                        if self.active_review_group == Some(hash) {
686                            return None; // already showing
687                        }
688                        self.active_review_group = Some(hash);
689                        self.review_scroll = 0;
690
691                        // If review isn't in memory yet, try disk cache
692                        if self.review_cache.get(&hash).is_none() {
693                            if let Some(cached) = crate::review::load_review_from_disk(hash, &self.review_source) {
694                                self.review_cache.insert(cached);
695                            }
696                        }
697                        // If still not cached (and not in-flight), spawn on demand as fallback
698                        if self.review_cache.get(&hash).is_none() {
699                            return self.spawn_all_reviews();
700                        }
701                    }
702                }
703                None
704            }
705            None => None,
706        }
707    }
708
709    /// Spawn reviews for ALL groups. Checks disk cache first, only spawns LLM
710    /// calls for groups without cached reviews. Returns a SpawnReviewBatch with
711    /// all needed sections, or None if everything is already cached.
712    pub fn spawn_all_reviews(&mut self) -> Option<Command> {
713        let backend = self.llm_backend?;
714        let groups = self.semantic_groups.as_ref()?.clone();
715        let mut all_cmds: Vec<Command> = Vec::new();
716
717        for group in &groups {
718            let hash = crate::review::group_content_hash(group);
719
720            // Already in memory cache?
721            if self.review_cache.get(&hash).is_some() {
722                continue;
723            }
724
725            // Check disk cache
726            if let Some(cached) = crate::review::load_review_from_disk(hash, &self.review_source) {
727                self.review_cache.insert(cached);
728                continue;
729            }
730
731            // Cache miss — prepare LLM calls for this group
732            let mut sections_map = std::collections::HashMap::new();
733            for s in ReviewSection::all() {
734                sections_map.insert(s, SectionState::Loading);
735            }
736            self.review_cache.insert(GroupReview {
737                content_hash: hash,
738                sections: sections_map,
739                source: self.review_source.clone(),
740            });
741
742            let model = self.llm_model.clone();
743            let review_source = self.review_source.clone();
744            for &section in &ReviewSection::all() {
745                let prompt = crate::review::llm::build_review_prompt(
746                    section, group, &self.diff_data, &review_source,
747                );
748                all_cmds.push(Command::SpawnReviewSection {
749                    backend,
750                    model: model.clone(),
751                    section,
752                    prompt,
753                    group_content_hash: hash,
754                });
755            }
756        }
757
758        if all_cmds.is_empty() {
759            None
760        } else {
761            Some(Command::SpawnReviewBatch(all_cmds))
762        }
763    }
764
765    /// Handle keys when the diff view is focused (original behavior).
766    fn handle_key_diff(&mut self, key: KeyEvent) -> Option<Command> {
767        // Review pane scroll and controls take priority when a review is active
768        if self.active_review_group.is_some() {
769            return self.handle_key_review(key);
770        }
771
772        // In preview mode, redirect navigation keys to preview scroll
773        if self.preview_mode {
774            return self.handle_key_preview(key);
775        }
776
777        let items_len = self.visible_items().len();
778        if items_len == 0 {
779            return None;
780        }
781
782        match key.code {
783            // Jump to next/previous search match
784            KeyCode::Char('n') => {
785                self.jump_to_match(true);
786                None
787            }
788            KeyCode::Char('N') => {
789                self.jump_to_match(false);
790                None
791            }
792
793            // Navigation
794            KeyCode::Char('j') | KeyCode::Down => {
795                self.move_selection(1, items_len);
796                None
797            }
798            KeyCode::Char('k') | KeyCode::Up => {
799                self.move_selection(-1, items_len);
800                None
801            }
802            KeyCode::Char('g') => {
803                self.ui_state.selected_index = 0;
804                self.adjust_scroll();
805                None
806            }
807            KeyCode::Char('G') => {
808                self.ui_state.selected_index = items_len.saturating_sub(1);
809                self.adjust_scroll();
810                None
811            }
812            KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
813                let half_page = (self.ui_state.viewport_height / 2) as usize;
814                self.move_selection(half_page as isize, items_len);
815                None
816            }
817            KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
818                let half_page = (self.ui_state.viewport_height / 2) as usize;
819                self.move_selection(-(half_page as isize), items_len);
820                None
821            }
822
823            // Collapse/Expand
824            KeyCode::Enter => {
825                self.toggle_collapse();
826                None
827            }
828
829            _ => None,
830        }
831    }
832
833    /// Filter the diff view to show only the hunks for the selected file within its group.
834    /// `group_idx` identifies which group the file was selected from (None = flat/ungrouped).
835    fn select_tree_file(&mut self, path: &str, group_idx: Option<usize>) {
836        let filter = self.hunk_filter_for_file(path, group_idx);
837        // Always apply the filter (don't toggle — that's what group headers are for)
838        self.tree_filter = Some(filter);
839        // Rebuild visible items and scroll to the selected file's header
840        let items = self.visible_items();
841        let target_idx = items.iter().position(|item| {
842            if let VisibleItem::FileHeader { file_idx } = item {
843                self.diff_data.files[*file_idx]
844                    .target_file
845                    .trim_start_matches("b/")
846                    == path
847            } else {
848                false
849            }
850        });
851        self.ui_state.selected_index = target_idx.unwrap_or(0);
852        // Pin scroll so the file header is at the top of the viewport
853        self.ui_state.scroll_offset = self.ui_state.selected_index as u16;
854    }
855
856    /// Filter the diff view to all changes in the selected group.
857    fn select_tree_group(&mut self, group_idx: usize) {
858        let filter = self.hunk_filter_for_group(group_idx);
859        if filter.is_empty() {
860            return;
861        }
862        self.tree_filter = Some(filter);
863        self.ui_state.selected_index = 0;
864        self.ui_state.scroll_offset = 0;
865    }
866
867    /// Build a HunkFilter for a single file's hunks within a specific group.
868    /// Only shows the hunks relevant to that group, not the entire group's files.
869    fn hunk_filter_for_file(&self, path: &str, group_idx: Option<usize>) -> HunkFilter {
870        if let Some(groups) = &self.semantic_groups {
871            if let Some(gi) = group_idx {
872                if gi >= groups.len() {
873                    // "Other" group — extract only this file's ungrouped hunks
874                    return self.hunk_filter_for_file_in_other(path);
875                }
876                if let Some(group) = groups.get(gi) {
877                    if let Some(filter) = self.hunk_filter_for_file_in_group(path, group) {
878                        return filter;
879                    }
880                }
881            }
882            // Fallback (no group_idx or file not found in specified group):
883            // search all groups for the first match
884            for group in groups.iter() {
885                if let Some(filter) = self.hunk_filter_for_file_in_group(path, group) {
886                    return filter;
887                }
888            }
889            return self.hunk_filter_for_file_in_other(path);
890        }
891        // No semantic groups — show all hunks for this file
892        let mut filter = HunkFilter::new();
893        filter.insert(path.to_string(), HashSet::new());
894        filter
895    }
896
897    /// Build a single-file HunkFilter from a specific group's changes.
898    fn hunk_filter_for_file_in_group(
899        &self,
900        path: &str,
901        group: &crate::grouper::SemanticGroup,
902    ) -> Option<HunkFilter> {
903        for change in &group.changes() {
904            if let Some(diff_path) = self.resolve_diff_path(&change.file) {
905                if diff_path == path {
906                    let mut filter = HunkFilter::new();
907                    let hunk_set: HashSet<usize> = change.hunks.iter().copied().collect();
908                    filter.insert(diff_path, hunk_set);
909                    return Some(filter);
910                }
911            }
912        }
913        None
914    }
915
916    /// Build a single-file HunkFilter from the "Other" (ungrouped) hunks.
917    fn hunk_filter_for_file_in_other(&self, path: &str) -> HunkFilter {
918        let other = self.hunk_filter_for_other();
919        let mut filter = HunkFilter::new();
920        if let Some(hunk_set) = other.get(path) {
921            filter.insert(path.to_string(), hunk_set.clone());
922        } else {
923            filter.insert(path.to_string(), HashSet::new());
924        }
925        filter
926    }
927
928    /// Build a HunkFilter for group at `group_idx`.
929    fn hunk_filter_for_group(&self, group_idx: usize) -> HunkFilter {
930        if let Some(groups) = &self.semantic_groups {
931            if let Some(group) = groups.get(group_idx) {
932                let mut filter = HunkFilter::new();
933                for change in &group.changes() {
934                    // Resolve to actual diff path
935                    if let Some(diff_path) = self.resolve_diff_path(&change.file) {
936                        let hunk_set: HashSet<usize> = change.hunks.iter().copied().collect();
937                        filter
938                            .entry(diff_path)
939                            .or_default()
940                            .extend(hunk_set.iter());
941                    }
942                }
943                return filter;
944            }
945            // group_idx beyond actual groups = "Other" group
946            if group_idx >= groups.len() {
947                return self.hunk_filter_for_other();
948            }
949        }
950        HunkFilter::new()
951    }
952
953    /// Build a HunkFilter for the "Other" group (ungrouped hunks).
954    fn hunk_filter_for_other(&self) -> HunkFilter {
955        let groups = match &self.semantic_groups {
956            Some(g) => g,
957            None => return HunkFilter::new(),
958        };
959
960        // Collect all grouped (file, hunk) pairs
961        let mut grouped: HashMap<String, HashSet<usize>> = HashMap::new();
962        for group in groups {
963            for change in &group.changes() {
964                if let Some(dp) = self.resolve_diff_path(&change.file) {
965                    grouped.entry(dp).or_default().extend(change.hunks.iter());
966                }
967            }
968        }
969
970        // For each diff file, include hunks NOT covered by any group
971        let mut filter = HunkFilter::new();
972        for file in &self.diff_data.files {
973            let dp = file.target_file.trim_start_matches("b/").to_string();
974            if let Some(grouped_hunks) = grouped.get(&dp) {
975                // If grouped_hunks is empty, all hunks are claimed
976                if grouped_hunks.is_empty() {
977                    continue;
978                }
979                let ungrouped: HashSet<usize> = (0..file.hunks.len())
980                    .filter(|hi| !grouped_hunks.contains(hi))
981                    .collect();
982                if !ungrouped.is_empty() {
983                    filter.insert(dp, ungrouped);
984                }
985            } else {
986                // File not in any group — all hunks are "other"
987                filter.insert(dp, HashSet::new());
988            }
989        }
990        filter
991    }
992
993    /// Resolve a group file path to the actual diff file path (stripped of b/ prefix).
994    fn resolve_diff_path(&self, group_path: &str) -> Option<String> {
995        self.diff_data.files.iter().find_map(|f| {
996            let dp = f.target_file.trim_start_matches("b/");
997            if dp == group_path || dp.ends_with(group_path) {
998                Some(dp.to_string())
999            } else {
1000                None
1001            }
1002        })
1003    }
1004
1005    /// Handle keys in Search mode.
1006    fn handle_key_search(&mut self, key: KeyEvent) -> Option<Command> {
1007        match key.code {
1008            KeyCode::Esc => {
1009                self.input_mode = InputMode::Normal;
1010                self.search_query.clear();
1011                self.active_filter = None;
1012                None
1013            }
1014            KeyCode::Enter => {
1015                self.input_mode = InputMode::Normal;
1016                self.active_filter = if self.search_query.is_empty() {
1017                    None
1018                } else {
1019                    Some(self.search_query.clone())
1020                };
1021                self.ui_state.selected_index = 0;
1022                self.adjust_scroll();
1023                None
1024            }
1025            KeyCode::Backspace => {
1026                self.search_query.pop();
1027                None
1028            }
1029            KeyCode::Char(c) => {
1030                self.search_query.push(c);
1031                None
1032            }
1033            _ => None,
1034        }
1035    }
1036
1037    /// Jump to the next or previous file header matching the active filter.
1038    fn jump_to_match(&mut self, forward: bool) {
1039        if self.active_filter.is_none() {
1040            return;
1041        }
1042        let items = self.visible_items();
1043        if items.is_empty() {
1044            return;
1045        }
1046
1047        let pattern = self.active_filter.as_ref().unwrap().to_lowercase();
1048        let len = items.len();
1049        let start = self.ui_state.selected_index;
1050
1051        // Search through all items wrapping around
1052        for offset in 1..=len {
1053            let idx = if forward {
1054                (start + offset) % len
1055            } else {
1056                (start + len - offset) % len
1057            };
1058            if let VisibleItem::FileHeader { file_idx } = &items[idx] {
1059                let path = &self.diff_data.files[*file_idx].target_file;
1060                if path.to_lowercase().contains(&pattern) {
1061                    self.ui_state.selected_index = idx;
1062                    self.adjust_scroll();
1063                    return;
1064                }
1065            }
1066        }
1067    }
1068
1069    /// Move selection by delta, clamping to valid range.
1070    fn move_selection(&mut self, delta: isize, items_len: usize) {
1071        let max_idx = items_len.saturating_sub(1);
1072        let new_idx = if delta > 0 {
1073            (self.ui_state.selected_index + delta as usize).min(max_idx)
1074        } else {
1075            self.ui_state.selected_index.saturating_sub((-delta) as usize)
1076        };
1077        self.ui_state.selected_index = new_idx;
1078        self.adjust_scroll();
1079    }
1080
1081    /// Toggle collapse on the currently selected item.
1082    fn toggle_collapse(&mut self) {
1083        let items = self.visible_items();
1084        if let Some(item) = items.get(self.ui_state.selected_index) {
1085            let node_id = match item {
1086                VisibleItem::FileHeader { file_idx } => Some(NodeId::File(*file_idx)),
1087                VisibleItem::HunkHeader { file_idx, hunk_idx } => {
1088                    Some(NodeId::Hunk(*file_idx, *hunk_idx))
1089                }
1090                VisibleItem::DiffLine { .. } => None, // no-op on diff lines
1091            };
1092
1093            if let Some(id) = node_id {
1094                if self.ui_state.collapsed.contains(&id) {
1095                    self.ui_state.collapsed.remove(&id);
1096                } else {
1097                    self.ui_state.collapsed.insert(id);
1098                }
1099
1100                // Clamp selected_index after collapse/expand changes visible items
1101                let new_items_len = self.visible_items().len();
1102                if self.ui_state.selected_index >= new_items_len {
1103                    self.ui_state.selected_index = new_items_len.saturating_sub(1);
1104                }
1105                self.adjust_scroll();
1106            }
1107        }
1108    }
1109
1110    /// Estimate the character width of a visible item's rendered line.
1111    fn item_char_width(&self, item: &VisibleItem) -> usize {
1112        match item {
1113            VisibleItem::FileHeader { file_idx } => {
1114                let file = &self.diff_data.files[*file_idx];
1115                let name = if file.is_rename {
1116                    format!(
1117                        "renamed: {} -> {}",
1118                        file.source_file.trim_start_matches("a/"),
1119                        file.target_file.trim_start_matches("b/")
1120                    )
1121                } else {
1122                    file.target_file.trim_start_matches("b/").to_string()
1123                };
1124                // " v " + name + " " + "+N" + " -N"
1125                3 + name.len()
1126                    + 1
1127                    + format!("+{}", file.added_count).len()
1128                    + format!(" -{}", file.removed_count).len()
1129            }
1130            VisibleItem::HunkHeader { file_idx, hunk_idx } => {
1131                let hunk = &self.diff_data.files[*file_idx].hunks[*hunk_idx];
1132                // "   v " + header
1133                5 + hunk.header.len()
1134            }
1135            VisibleItem::DiffLine {
1136                file_idx,
1137                hunk_idx,
1138                line_idx,
1139            } => {
1140                let line =
1141                    &self.diff_data.files[*file_idx].hunks[*hunk_idx].lines[*line_idx];
1142                // gutter (10) + prefix (2) + content
1143                12 + line.content.len()
1144            }
1145        }
1146    }
1147
1148    /// Calculate the visual row count for an item given the available width.
1149    pub fn item_visual_rows(&self, item: &VisibleItem, width: u16) -> usize {
1150        if width == 0 {
1151            return 1;
1152        }
1153        let char_width = self.item_char_width(item);
1154        char_width.div_ceil(width as usize).max(1)
1155    }
1156
1157    /// Adjust scroll offset to keep the selected item visible,
1158    /// accounting for line wrapping.
1159    fn adjust_scroll(&mut self) {
1160        let width = self.ui_state.diff_view_width.get();
1161        let viewport = self.ui_state.viewport_height as usize;
1162        let items = self.visible_items();
1163        let selected = self.ui_state.selected_index;
1164
1165        if items.is_empty() || viewport == 0 {
1166            self.ui_state.scroll_offset = 0;
1167            return;
1168        }
1169
1170        let scroll = self.ui_state.scroll_offset as usize;
1171
1172        // Selected is above viewport
1173        if selected < scroll {
1174            self.ui_state.scroll_offset = selected as u16;
1175            return;
1176        }
1177
1178        // Check if selected fits within viewport from current scroll
1179        let mut rows = 0usize;
1180        for (i, item) in items.iter().enumerate().take(selected + 1).skip(scroll) {
1181            rows += self.item_visual_rows(item, width);
1182            if rows > viewport && i < selected {
1183                break;
1184            }
1185        }
1186
1187        if rows <= viewport {
1188            return;
1189        }
1190
1191        // Selected is below viewport — find scroll that shows it at bottom
1192        let selected_height = self.item_visual_rows(&items[selected], width);
1193        if selected_height >= viewport {
1194            self.ui_state.scroll_offset = selected as u16;
1195            return;
1196        }
1197
1198        let mut remaining = viewport - selected_height;
1199        let mut new_scroll = selected;
1200        for i in (0..selected).rev() {
1201            let h = self.item_visual_rows(&items[i], width);
1202            if h > remaining {
1203                break;
1204            }
1205            remaining -= h;
1206            new_scroll = i;
1207        }
1208        self.ui_state.scroll_offset = new_scroll as u16;
1209    }
1210
1211    /// Compute the list of visible items respecting collapsed state, active filter,
1212    /// and hunk-level tree filter.
1213    pub fn visible_items(&self) -> Vec<VisibleItem> {
1214        let filter_lower = self
1215            .active_filter
1216            .as_ref()
1217            .map(|f| f.to_lowercase());
1218
1219        let mut items = Vec::new();
1220        for (fi, file) in self.diff_data.files.iter().enumerate() {
1221            let file_path = file.target_file.trim_start_matches("b/");
1222
1223            // If search filter is active, skip files that don't match
1224            if let Some(ref pattern) = filter_lower {
1225                if !file.target_file.to_lowercase().contains(pattern) {
1226                    continue;
1227                }
1228            }
1229
1230            // Determine which hunks are visible based on tree filter
1231            let allowed_hunks: Option<&HashSet<usize>> =
1232                self.tree_filter.as_ref().and_then(|f| f.get(file_path));
1233
1234            // If tree filter is active but this file isn't in it, skip entirely
1235            if self.tree_filter.is_some() && allowed_hunks.is_none() {
1236                continue;
1237            }
1238
1239            items.push(VisibleItem::FileHeader { file_idx: fi });
1240            if !self.ui_state.collapsed.contains(&NodeId::File(fi)) {
1241                for (hi, hunk) in file.hunks.iter().enumerate() {
1242                    // If hunk filter is active and this hunk isn't in the set, skip it
1243                    // (empty set = show all hunks for this file)
1244                    if let Some(hunk_set) = allowed_hunks {
1245                        if !hunk_set.is_empty() && !hunk_set.contains(&hi) {
1246                            continue;
1247                        }
1248                    }
1249
1250                    items.push(VisibleItem::HunkHeader {
1251                        file_idx: fi,
1252                        hunk_idx: hi,
1253                    });
1254                    if !self.ui_state.collapsed.contains(&NodeId::Hunk(fi, hi)) {
1255                        for (li, _line) in hunk.lines.iter().enumerate() {
1256                            items.push(VisibleItem::DiffLine {
1257                                file_idx: fi,
1258                                hunk_idx: hi,
1259                                line_idx: li,
1260                            });
1261                        }
1262                    }
1263                }
1264            }
1265        }
1266        items
1267    }
1268
1269    /// Handle keys when the review pane is active.
1270    fn handle_key_review(&mut self, key: KeyEvent) -> Option<Command> {
1271        match key.code {
1272            KeyCode::Char('j') | KeyCode::Down => {
1273                self.review_scroll = self.review_scroll.saturating_add(1);
1274                None
1275            }
1276            KeyCode::Char('k') | KeyCode::Up => {
1277                self.review_scroll = self.review_scroll.saturating_sub(1);
1278                None
1279            }
1280            KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1281                self.review_scroll = self.review_scroll.saturating_add(
1282                    (self.ui_state.viewport_height / 2) as usize,
1283                );
1284                None
1285            }
1286            KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1287                self.review_scroll = self.review_scroll.saturating_sub(
1288                    (self.ui_state.viewport_height / 2) as usize,
1289                );
1290                None
1291            }
1292            KeyCode::Char('g') => {
1293                self.review_scroll = 0;
1294                None
1295            }
1296            KeyCode::Char('G') => {
1297                self.review_scroll = 999; // will be clamped by renderer
1298                None
1299            }
1300            KeyCode::Char('R') => {
1301                // Force-refresh review
1302                if let Some(hash) = self.active_review_group {
1303                    // Cancel in-flight tasks for this hash
1304                    let keys: Vec<_> = self
1305                        .review_handles
1306                        .keys()
1307                        .filter(|(h, _)| *h == hash)
1308                        .cloned()
1309                        .collect();
1310                    for key in keys {
1311                        if let Some(handle) = self.review_handles.remove(&key) {
1312                            handle.abort();
1313                        }
1314                    }
1315                    // Clear caches
1316                    self.review_cache.remove(&hash);
1317                    crate::review::delete_review_from_disk(hash);
1318                    self.active_review_group = None;
1319
1320                    // Re-trigger by re-applying current tree selection
1321                    let selected = self.tree_state.borrow().selected().to_vec();
1322                    return self.apply_tree_selection(&selected);
1323                }
1324                None
1325            }
1326            KeyCode::Esc => {
1327                // Clear review mode
1328                if let Some(old_hash) = self.active_review_group.take() {
1329                    let keys: Vec<_> = self
1330                        .review_handles
1331                        .keys()
1332                        .filter(|(h, _)| *h == old_hash)
1333                        .cloned()
1334                        .collect();
1335                    for key in keys {
1336                        if let Some(handle) = self.review_handles.remove(&key) {
1337                            handle.abort();
1338                        }
1339                    }
1340                }
1341                self.review_scroll = 0;
1342                None
1343            }
1344            _ => None,
1345        }
1346    }
1347
1348    /// Handle keys in preview mode (line-based scrolling).
1349    fn handle_key_preview(&mut self, key: KeyEvent) -> Option<Command> {
1350        match key.code {
1351            KeyCode::Char('j') | KeyCode::Down => {
1352                self.ui_state.preview_scroll = self.ui_state.preview_scroll.saturating_add(1);
1353                None
1354            }
1355            KeyCode::Char('k') | KeyCode::Up => {
1356                self.ui_state.preview_scroll = self.ui_state.preview_scroll.saturating_sub(1);
1357                None
1358            }
1359            KeyCode::Char('g') => {
1360                self.ui_state.preview_scroll = 0;
1361                None
1362            }
1363            KeyCode::Char('G') => {
1364                self.ui_state.preview_scroll = usize::MAX; // will be clamped in render
1365                None
1366            }
1367            KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1368                let half_page = (self.ui_state.viewport_height / 2) as usize;
1369                self.ui_state.preview_scroll = self.ui_state.preview_scroll.saturating_add(half_page);
1370                None
1371            }
1372            KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1373                let half_page = (self.ui_state.viewport_height / 2) as usize;
1374                self.ui_state.preview_scroll = self.ui_state.preview_scroll.saturating_sub(half_page);
1375                None
1376            }
1377            // Collapse/expand still works in preview mode (no-op but don't block)
1378            KeyCode::Enter => None,
1379            // Search match jumping
1380            KeyCode::Char('n') | KeyCode::Char('N') => None,
1381            _ => None,
1382        }
1383    }
1384
1385    /// TEA view: delegate rendering to the UI module.
1386    /// Returns pending images that must be flushed after terminal.draw().
1387    pub fn view(&self, frame: &mut ratatui::Frame) -> Vec<crate::ui::preview_view::PendingImage> {
1388        crate::ui::draw(self, frame)
1389    }
1390}