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