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