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                None
530            }
531            KeyCode::Char('k') | KeyCode::Up => {
532                ts.key_up();
533                None
534            }
535            KeyCode::Left => {
536                ts.key_left();
537                None
538            }
539            KeyCode::Right => {
540                ts.key_right();
541                None
542            }
543            KeyCode::Enter => {
544                let selected = ts.selected().to_vec();
545                drop(ts); // release borrow before mutating self
546                if let Some(last) = selected.last() {
547                    match last {
548                        TreeNodeId::File(path) => {
549                            self.select_tree_file(path);
550                        }
551                        TreeNodeId::Group(gi) => {
552                            self.select_tree_group(*gi);
553                        }
554                    }
555                }
556                None
557            }
558            KeyCode::Char('g') => {
559                ts.select_first();
560                None
561            }
562            KeyCode::Char('G') => {
563                ts.select_last();
564                None
565            }
566            _ => None,
567        }
568    }
569
570    /// Handle keys when the diff view is focused (original behavior).
571    fn handle_key_diff(&mut self, key: KeyEvent) -> Option<Command> {
572        let items_len = self.visible_items().len();
573        if items_len == 0 {
574            return None;
575        }
576
577        match key.code {
578            // Jump to next/previous search match
579            KeyCode::Char('n') => {
580                self.jump_to_match(true);
581                None
582            }
583            KeyCode::Char('N') => {
584                self.jump_to_match(false);
585                None
586            }
587
588            // Navigation
589            KeyCode::Char('j') | KeyCode::Down => {
590                self.move_selection(1, items_len);
591                None
592            }
593            KeyCode::Char('k') | KeyCode::Up => {
594                self.move_selection(-1, items_len);
595                None
596            }
597            KeyCode::Char('g') => {
598                self.ui_state.selected_index = 0;
599                self.adjust_scroll();
600                None
601            }
602            KeyCode::Char('G') => {
603                self.ui_state.selected_index = items_len.saturating_sub(1);
604                self.adjust_scroll();
605                None
606            }
607            KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
608                let half_page = (self.ui_state.viewport_height / 2) as usize;
609                self.move_selection(half_page as isize, items_len);
610                None
611            }
612            KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
613                let half_page = (self.ui_state.viewport_height / 2) as usize;
614                self.move_selection(-(half_page as isize), items_len);
615                None
616            }
617
618            // Collapse/Expand
619            KeyCode::Enter => {
620                self.toggle_collapse();
621                None
622            }
623
624            _ => None,
625        }
626    }
627
628    /// Filter the diff view to the group containing the selected file, and scroll to it.
629    /// If the group is already active, just scroll to the file without toggling off.
630    fn select_tree_file(&mut self, path: &str) {
631        let filter = self.hunk_filter_for_file(path);
632        // Always apply the group filter (don't toggle — that's what group headers are for)
633        self.tree_filter = Some(filter);
634        // Rebuild visible items and scroll to the selected file's header
635        let items = self.visible_items();
636        let target_idx = items.iter().position(|item| {
637            if let VisibleItem::FileHeader { file_idx } = item {
638                self.diff_data.files[*file_idx]
639                    .target_file
640                    .trim_start_matches("b/")
641                    == path
642            } else {
643                false
644            }
645        });
646        self.ui_state.selected_index = target_idx.unwrap_or(0);
647        // Pin scroll so the file header is at the top of the viewport
648        self.ui_state.scroll_offset = self.ui_state.selected_index as u16;
649    }
650
651    /// Filter the diff view to all changes in the selected group.
652    fn select_tree_group(&mut self, group_idx: usize) {
653        let filter = self.hunk_filter_for_group(group_idx);
654        if filter.is_empty() {
655            self.tree_state.borrow_mut().toggle_selected();
656            return;
657        }
658        // Toggle: if already filtering to this group, clear it
659        if self.tree_filter.as_ref() == Some(&filter) {
660            self.tree_filter = None;
661        } else {
662            self.tree_filter = Some(filter);
663        }
664        self.ui_state.selected_index = 0;
665        self.ui_state.scroll_offset = 0;
666    }
667
668    /// Build a HunkFilter for the group containing `path`.
669    /// Falls back to showing just that file (all hunks) if no groups exist.
670    fn hunk_filter_for_file(&self, path: &str) -> HunkFilter {
671        if let Some(groups) = &self.semantic_groups {
672            for (gi, group) in groups.iter().enumerate() {
673                let has_file = group.changes().iter().any(|c| {
674                    c.file == path || path.ends_with(c.file.as_str()) || c.file.ends_with(path)
675                });
676                if has_file {
677                    return self.hunk_filter_for_group(gi);
678                }
679            }
680            // File is in the "Other" group — collect ungrouped file/hunks
681            return self.hunk_filter_for_other();
682        }
683        // No semantic groups — filter to just this file (all hunks)
684        let mut filter = HunkFilter::new();
685        filter.insert(path.to_string(), HashSet::new());
686        filter
687    }
688
689    /// Build a HunkFilter for group at `group_idx`.
690    fn hunk_filter_for_group(&self, group_idx: usize) -> HunkFilter {
691        if let Some(groups) = &self.semantic_groups {
692            if let Some(group) = groups.get(group_idx) {
693                let mut filter = HunkFilter::new();
694                for change in &group.changes() {
695                    // Resolve to actual diff path
696                    if let Some(diff_path) = self.resolve_diff_path(&change.file) {
697                        let hunk_set: HashSet<usize> = change.hunks.iter().copied().collect();
698                        filter
699                            .entry(diff_path)
700                            .or_default()
701                            .extend(hunk_set.iter());
702                    }
703                }
704                return filter;
705            }
706            // group_idx beyond actual groups = "Other" group
707            if group_idx >= groups.len() {
708                return self.hunk_filter_for_other();
709            }
710        }
711        HunkFilter::new()
712    }
713
714    /// Build a HunkFilter for the "Other" group (ungrouped hunks).
715    fn hunk_filter_for_other(&self) -> HunkFilter {
716        let groups = match &self.semantic_groups {
717            Some(g) => g,
718            None => return HunkFilter::new(),
719        };
720
721        // Collect all grouped (file, hunk) pairs
722        let mut grouped: HashMap<String, HashSet<usize>> = HashMap::new();
723        for group in groups {
724            for change in &group.changes() {
725                if let Some(dp) = self.resolve_diff_path(&change.file) {
726                    grouped.entry(dp).or_default().extend(change.hunks.iter());
727                }
728            }
729        }
730
731        // For each diff file, include hunks NOT covered by any group
732        let mut filter = HunkFilter::new();
733        for file in &self.diff_data.files {
734            let dp = file.target_file.trim_start_matches("b/").to_string();
735            if let Some(grouped_hunks) = grouped.get(&dp) {
736                // If grouped_hunks is empty, all hunks are claimed
737                if grouped_hunks.is_empty() {
738                    continue;
739                }
740                let ungrouped: HashSet<usize> = (0..file.hunks.len())
741                    .filter(|hi| !grouped_hunks.contains(hi))
742                    .collect();
743                if !ungrouped.is_empty() {
744                    filter.insert(dp, ungrouped);
745                }
746            } else {
747                // File not in any group — all hunks are "other"
748                filter.insert(dp, HashSet::new());
749            }
750        }
751        filter
752    }
753
754    /// Resolve a group file path to the actual diff file path (stripped of b/ prefix).
755    fn resolve_diff_path(&self, group_path: &str) -> Option<String> {
756        self.diff_data.files.iter().find_map(|f| {
757            let dp = f.target_file.trim_start_matches("b/");
758            if dp == group_path || dp.ends_with(group_path) {
759                Some(dp.to_string())
760            } else {
761                None
762            }
763        })
764    }
765
766    /// Handle keys in Search mode.
767    fn handle_key_search(&mut self, key: KeyEvent) -> Option<Command> {
768        match key.code {
769            KeyCode::Esc => {
770                self.input_mode = InputMode::Normal;
771                self.search_query.clear();
772                self.active_filter = None;
773                None
774            }
775            KeyCode::Enter => {
776                self.input_mode = InputMode::Normal;
777                self.active_filter = if self.search_query.is_empty() {
778                    None
779                } else {
780                    Some(self.search_query.clone())
781                };
782                self.ui_state.selected_index = 0;
783                self.adjust_scroll();
784                None
785            }
786            KeyCode::Backspace => {
787                self.search_query.pop();
788                None
789            }
790            KeyCode::Char(c) => {
791                self.search_query.push(c);
792                None
793            }
794            _ => None,
795        }
796    }
797
798    /// Jump to the next or previous file header matching the active filter.
799    fn jump_to_match(&mut self, forward: bool) {
800        if self.active_filter.is_none() {
801            return;
802        }
803        let items = self.visible_items();
804        if items.is_empty() {
805            return;
806        }
807
808        let pattern = self.active_filter.as_ref().unwrap().to_lowercase();
809        let len = items.len();
810        let start = self.ui_state.selected_index;
811
812        // Search through all items wrapping around
813        for offset in 1..=len {
814            let idx = if forward {
815                (start + offset) % len
816            } else {
817                (start + len - offset) % len
818            };
819            if let VisibleItem::FileHeader { file_idx } = &items[idx] {
820                let path = &self.diff_data.files[*file_idx].target_file;
821                if path.to_lowercase().contains(&pattern) {
822                    self.ui_state.selected_index = idx;
823                    self.adjust_scroll();
824                    return;
825                }
826            }
827        }
828    }
829
830    /// Move selection by delta, clamping to valid range.
831    fn move_selection(&mut self, delta: isize, items_len: usize) {
832        let max_idx = items_len.saturating_sub(1);
833        let new_idx = if delta > 0 {
834            (self.ui_state.selected_index + delta as usize).min(max_idx)
835        } else {
836            self.ui_state.selected_index.saturating_sub((-delta) as usize)
837        };
838        self.ui_state.selected_index = new_idx;
839        self.adjust_scroll();
840    }
841
842    /// Toggle collapse on the currently selected item.
843    fn toggle_collapse(&mut self) {
844        let items = self.visible_items();
845        if let Some(item) = items.get(self.ui_state.selected_index) {
846            let node_id = match item {
847                VisibleItem::FileHeader { file_idx } => Some(NodeId::File(*file_idx)),
848                VisibleItem::HunkHeader { file_idx, hunk_idx } => {
849                    Some(NodeId::Hunk(*file_idx, *hunk_idx))
850                }
851                VisibleItem::DiffLine { .. } => None, // no-op on diff lines
852            };
853
854            if let Some(id) = node_id {
855                if self.ui_state.collapsed.contains(&id) {
856                    self.ui_state.collapsed.remove(&id);
857                } else {
858                    self.ui_state.collapsed.insert(id);
859                }
860
861                // Clamp selected_index after collapse/expand changes visible items
862                let new_items_len = self.visible_items().len();
863                if self.ui_state.selected_index >= new_items_len {
864                    self.ui_state.selected_index = new_items_len.saturating_sub(1);
865                }
866                self.adjust_scroll();
867            }
868        }
869    }
870
871    /// Estimate the character width of a visible item's rendered line.
872    fn item_char_width(&self, item: &VisibleItem) -> usize {
873        match item {
874            VisibleItem::FileHeader { file_idx } => {
875                let file = &self.diff_data.files[*file_idx];
876                let name = if file.is_rename {
877                    format!(
878                        "renamed: {} -> {}",
879                        file.source_file.trim_start_matches("a/"),
880                        file.target_file.trim_start_matches("b/")
881                    )
882                } else {
883                    file.target_file.trim_start_matches("b/").to_string()
884                };
885                // " v " + name + " " + "+N" + " -N"
886                3 + name.len()
887                    + 1
888                    + format!("+{}", file.added_count).len()
889                    + format!(" -{}", file.removed_count).len()
890            }
891            VisibleItem::HunkHeader { file_idx, hunk_idx } => {
892                let hunk = &self.diff_data.files[*file_idx].hunks[*hunk_idx];
893                // "   v " + header
894                5 + hunk.header.len()
895            }
896            VisibleItem::DiffLine {
897                file_idx,
898                hunk_idx,
899                line_idx,
900            } => {
901                let line =
902                    &self.diff_data.files[*file_idx].hunks[*hunk_idx].lines[*line_idx];
903                // gutter (10) + prefix (2) + content
904                12 + line.content.len()
905            }
906        }
907    }
908
909    /// Calculate the visual row count for an item given the available width.
910    pub fn item_visual_rows(&self, item: &VisibleItem, width: u16) -> usize {
911        if width == 0 {
912            return 1;
913        }
914        let char_width = self.item_char_width(item);
915        char_width.div_ceil(width as usize).max(1)
916    }
917
918    /// Adjust scroll offset to keep the selected item visible,
919    /// accounting for line wrapping.
920    fn adjust_scroll(&mut self) {
921        let width = self.ui_state.diff_view_width.get();
922        let viewport = self.ui_state.viewport_height as usize;
923        let items = self.visible_items();
924        let selected = self.ui_state.selected_index;
925
926        if items.is_empty() || viewport == 0 {
927            self.ui_state.scroll_offset = 0;
928            return;
929        }
930
931        let scroll = self.ui_state.scroll_offset as usize;
932
933        // Selected is above viewport
934        if selected < scroll {
935            self.ui_state.scroll_offset = selected as u16;
936            return;
937        }
938
939        // Check if selected fits within viewport from current scroll
940        let mut rows = 0usize;
941        for (i, item) in items.iter().enumerate().take(selected + 1).skip(scroll) {
942            rows += self.item_visual_rows(item, width);
943            if rows > viewport && i < selected {
944                break;
945            }
946        }
947
948        if rows <= viewport {
949            return;
950        }
951
952        // Selected is below viewport — find scroll that shows it at bottom
953        let selected_height = self.item_visual_rows(&items[selected], width);
954        if selected_height >= viewport {
955            self.ui_state.scroll_offset = selected as u16;
956            return;
957        }
958
959        let mut remaining = viewport - selected_height;
960        let mut new_scroll = selected;
961        for i in (0..selected).rev() {
962            let h = self.item_visual_rows(&items[i], width);
963            if h > remaining {
964                break;
965            }
966            remaining -= h;
967            new_scroll = i;
968        }
969        self.ui_state.scroll_offset = new_scroll as u16;
970    }
971
972    /// Compute the list of visible items respecting collapsed state, active filter,
973    /// and hunk-level tree filter.
974    pub fn visible_items(&self) -> Vec<VisibleItem> {
975        let filter_lower = self
976            .active_filter
977            .as_ref()
978            .map(|f| f.to_lowercase());
979
980        let mut items = Vec::new();
981        for (fi, file) in self.diff_data.files.iter().enumerate() {
982            let file_path = file.target_file.trim_start_matches("b/");
983
984            // If search filter is active, skip files that don't match
985            if let Some(ref pattern) = filter_lower {
986                if !file.target_file.to_lowercase().contains(pattern) {
987                    continue;
988                }
989            }
990
991            // Determine which hunks are visible based on tree filter
992            let allowed_hunks: Option<&HashSet<usize>> =
993                self.tree_filter.as_ref().and_then(|f| f.get(file_path));
994
995            // If tree filter is active but this file isn't in it, skip entirely
996            if self.tree_filter.is_some() && allowed_hunks.is_none() {
997                continue;
998            }
999
1000            items.push(VisibleItem::FileHeader { file_idx: fi });
1001            if !self.ui_state.collapsed.contains(&NodeId::File(fi)) {
1002                for (hi, hunk) in file.hunks.iter().enumerate() {
1003                    // If hunk filter is active and this hunk isn't in the set, skip it
1004                    // (empty set = show all hunks for this file)
1005                    if let Some(hunk_set) = allowed_hunks {
1006                        if !hunk_set.is_empty() && !hunk_set.contains(&hi) {
1007                            continue;
1008                        }
1009                    }
1010
1011                    items.push(VisibleItem::HunkHeader {
1012                        file_idx: fi,
1013                        hunk_idx: hi,
1014                    });
1015                    if !self.ui_state.collapsed.contains(&NodeId::Hunk(fi, hi)) {
1016                        for (li, _line) in hunk.lines.iter().enumerate() {
1017                            items.push(VisibleItem::DiffLine {
1018                                file_idx: fi,
1019                                hunk_idx: hi,
1020                                line_idx: li,
1021                            });
1022                        }
1023                    }
1024                }
1025            }
1026        }
1027        items
1028    }
1029
1030    /// TEA view: delegate rendering to the UI module.
1031    pub fn view(&self, frame: &mut ratatui::Frame) {
1032        crate::ui::draw(self, frame);
1033    }
1034}