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