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