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