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
13pub type HunkFilter = HashMap<String, HashSet<usize>>;
16
17#[derive(Debug, Clone, PartialEq)]
19pub enum InputMode {
20 Normal,
21 Search,
22 Help,
23}
24
25#[derive(Debug, Clone, PartialEq)]
27pub enum FocusedPanel {
28 FileTree,
29 DiffView,
30}
31
32#[derive(Debug)]
34pub enum Message {
35 KeyPress(KeyEvent),
36 Resize(u16, u16),
37 RefreshSignal,
38 DebouncedRefresh,
39 DiffParsed(DiffData, String), GroupingComplete(Vec<SemanticGroup>, u64), GroupingFailed(String),
42 IncrementalGroupingComplete(
43 Vec<SemanticGroup>,
44 crate::grouper::DiffDelta,
45 HashMap<String, u64>,
46 u64, String, ),
49}
50
51
52#[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#[derive(Debug, Clone, Hash, Eq, PartialEq)]
78pub enum NodeId {
79 File(usize),
80 Hunk(usize, usize),
81}
82
83pub struct UiState {
85 pub selected_index: usize,
86 pub scroll_offset: u16,
87 pub collapsed: HashSet<NodeId>,
88 pub viewport_height: u16,
90 pub diff_view_width: Cell<u16>,
92}
93
94#[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
102pub 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 pub event_tx: Option<mpsc::Sender<Message>>,
111 pub debounce_handle: Option<tokio::task::JoinHandle<()>>,
113 pub input_mode: InputMode,
115 pub search_query: String,
117 pub active_filter: Option<String>,
119 pub semantic_groups: Option<Vec<SemanticGroup>>,
121 pub grouping_status: GroupingStatus,
123 pub grouping_handle: Option<tokio::task::JoinHandle<()>>,
125 pub llm_backend: Option<LlmBackend>,
127 pub llm_model: String,
129 pub focused_panel: FocusedPanel,
131 pub tree_state: RefCell<TreeState<TreeNodeId>>,
133 pub tree_filter: Option<HunkFilter>,
136 pub theme: Theme,
138 pub previous_head: Option<String>,
140 pub previous_file_hashes: HashMap<String, u64>,
142 pub git_diff_args: Vec<String>,
144}
145
146impl App {
147 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, 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 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 if let Some(handle) = self.debounce_handle.take() {
196 handle.abort();
197 }
198 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 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 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 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 self.grouping_status = GroupingStatus::Done;
249 return None;
250 }
251
252 if delta.is_only_removals() {
253 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 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 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 if let Some(backend) = self.llm_backend {
305 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 let current_head = crate::cache::get_head_commit();
330 let file_hashes = crate::grouper::compute_all_file_hashes(&self.diff_data);
331 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 let mut ts = self.tree_state.borrow_mut();
345 *ts = TreeState::default();
346 ts.select_first();
347 drop(ts);
348 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 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 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 }
383 }
384 }
385
386 fn apply_new_diff_data(&mut self, new_data: DiffData) {
388 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 let selected_path = self.selected_file_path();
409
410 self.diff_data = new_data;
412 self.highlight_cache = HighlightCache::new(&self.diff_data, self.theme.syntect_theme);
413
414 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 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 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 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 self.input_mode = InputMode::Normal;
476 None
477 }
478 }
479 }
480
481 fn handle_key_normal(&mut self, key: KeyEvent) -> Option<Command> {
483 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 match self.focused_panel {
518 FocusedPanel::FileTree => self.handle_key_tree(key),
519 FocusedPanel::DiffView => self.handle_key_diff(key),
520 }
521 }
522
523 fn handle_key_tree(&mut self, key: KeyEvent) -> Option<Command> {
525 let mut ts = self.tree_state.borrow_mut();
526 match key.code {
527 KeyCode::Char('j') | KeyCode::Down => {
528 ts.key_down();
529 }
530 KeyCode::Char('k') | KeyCode::Up => {
531 ts.key_up();
532 }
533 KeyCode::Left => {
534 ts.key_left();
535 }
536 KeyCode::Right => {
537 ts.key_right();
538 }
539 KeyCode::Enter => {
540 ts.toggle_selected();
541 }
542 KeyCode::Char('g') => {
543 ts.select_first();
544 }
545 KeyCode::Char('G') => {
546 ts.select_last();
547 }
548 _ => return None,
549 }
550
551 let selected = ts.selected().to_vec();
553 drop(ts); self.apply_tree_selection(&selected);
555 None
556 }
557
558 fn apply_tree_selection(&mut self, selected: &[TreeNodeId]) {
560 match selected.last() {
561 Some(TreeNodeId::File(group_idx, path)) => {
562 self.select_tree_file(path, *group_idx);
563 }
564 Some(TreeNodeId::Group(gi)) => {
565 self.select_tree_group(*gi);
566 }
567 None => {}
568 }
569 }
570
571 fn handle_key_diff(&mut self, key: KeyEvent) -> Option<Command> {
573 let items_len = self.visible_items().len();
574 if items_len == 0 {
575 return None;
576 }
577
578 match key.code {
579 KeyCode::Char('n') => {
581 self.jump_to_match(true);
582 None
583 }
584 KeyCode::Char('N') => {
585 self.jump_to_match(false);
586 None
587 }
588
589 KeyCode::Char('j') | KeyCode::Down => {
591 self.move_selection(1, items_len);
592 None
593 }
594 KeyCode::Char('k') | KeyCode::Up => {
595 self.move_selection(-1, items_len);
596 None
597 }
598 KeyCode::Char('g') => {
599 self.ui_state.selected_index = 0;
600 self.adjust_scroll();
601 None
602 }
603 KeyCode::Char('G') => {
604 self.ui_state.selected_index = items_len.saturating_sub(1);
605 self.adjust_scroll();
606 None
607 }
608 KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
609 let half_page = (self.ui_state.viewport_height / 2) as usize;
610 self.move_selection(half_page as isize, items_len);
611 None
612 }
613 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
614 let half_page = (self.ui_state.viewport_height / 2) as usize;
615 self.move_selection(-(half_page as isize), items_len);
616 None
617 }
618
619 KeyCode::Enter => {
621 self.toggle_collapse();
622 None
623 }
624
625 _ => None,
626 }
627 }
628
629 fn select_tree_file(&mut self, path: &str, group_idx: Option<usize>) {
632 let filter = self.hunk_filter_for_file(path, group_idx);
633 self.tree_filter = Some(filter);
635 let items = self.visible_items();
637 let target_idx = items.iter().position(|item| {
638 if let VisibleItem::FileHeader { file_idx } = item {
639 self.diff_data.files[*file_idx]
640 .target_file
641 .trim_start_matches("b/")
642 == path
643 } else {
644 false
645 }
646 });
647 self.ui_state.selected_index = target_idx.unwrap_or(0);
648 self.ui_state.scroll_offset = self.ui_state.selected_index as u16;
650 }
651
652 fn select_tree_group(&mut self, group_idx: usize) {
654 let filter = self.hunk_filter_for_group(group_idx);
655 if filter.is_empty() {
656 return;
657 }
658 self.tree_filter = Some(filter);
659 self.ui_state.selected_index = 0;
660 self.ui_state.scroll_offset = 0;
661 }
662
663 fn hunk_filter_for_file(&self, path: &str, group_idx: Option<usize>) -> HunkFilter {
666 if let Some(groups) = &self.semantic_groups {
667 if let Some(gi) = group_idx {
668 if gi >= groups.len() {
669 return self.hunk_filter_for_file_in_other(path);
671 }
672 if let Some(group) = groups.get(gi) {
673 if let Some(filter) = self.hunk_filter_for_file_in_group(path, group) {
674 return filter;
675 }
676 }
677 }
678 for group in groups.iter() {
681 if let Some(filter) = self.hunk_filter_for_file_in_group(path, group) {
682 return filter;
683 }
684 }
685 return self.hunk_filter_for_file_in_other(path);
686 }
687 let mut filter = HunkFilter::new();
689 filter.insert(path.to_string(), HashSet::new());
690 filter
691 }
692
693 fn hunk_filter_for_file_in_group(
695 &self,
696 path: &str,
697 group: &crate::grouper::SemanticGroup,
698 ) -> Option<HunkFilter> {
699 for change in &group.changes() {
700 if let Some(diff_path) = self.resolve_diff_path(&change.file) {
701 if diff_path == path {
702 let mut filter = HunkFilter::new();
703 let hunk_set: HashSet<usize> = change.hunks.iter().copied().collect();
704 filter.insert(diff_path, hunk_set);
705 return Some(filter);
706 }
707 }
708 }
709 None
710 }
711
712 fn hunk_filter_for_file_in_other(&self, path: &str) -> HunkFilter {
714 let other = self.hunk_filter_for_other();
715 let mut filter = HunkFilter::new();
716 if let Some(hunk_set) = other.get(path) {
717 filter.insert(path.to_string(), hunk_set.clone());
718 } else {
719 filter.insert(path.to_string(), HashSet::new());
720 }
721 filter
722 }
723
724 fn hunk_filter_for_group(&self, group_idx: usize) -> HunkFilter {
726 if let Some(groups) = &self.semantic_groups {
727 if let Some(group) = groups.get(group_idx) {
728 let mut filter = HunkFilter::new();
729 for change in &group.changes() {
730 if let Some(diff_path) = self.resolve_diff_path(&change.file) {
732 let hunk_set: HashSet<usize> = change.hunks.iter().copied().collect();
733 filter
734 .entry(diff_path)
735 .or_default()
736 .extend(hunk_set.iter());
737 }
738 }
739 return filter;
740 }
741 if group_idx >= groups.len() {
743 return self.hunk_filter_for_other();
744 }
745 }
746 HunkFilter::new()
747 }
748
749 fn hunk_filter_for_other(&self) -> HunkFilter {
751 let groups = match &self.semantic_groups {
752 Some(g) => g,
753 None => return HunkFilter::new(),
754 };
755
756 let mut grouped: HashMap<String, HashSet<usize>> = HashMap::new();
758 for group in groups {
759 for change in &group.changes() {
760 if let Some(dp) = self.resolve_diff_path(&change.file) {
761 grouped.entry(dp).or_default().extend(change.hunks.iter());
762 }
763 }
764 }
765
766 let mut filter = HunkFilter::new();
768 for file in &self.diff_data.files {
769 let dp = file.target_file.trim_start_matches("b/").to_string();
770 if let Some(grouped_hunks) = grouped.get(&dp) {
771 if grouped_hunks.is_empty() {
773 continue;
774 }
775 let ungrouped: HashSet<usize> = (0..file.hunks.len())
776 .filter(|hi| !grouped_hunks.contains(hi))
777 .collect();
778 if !ungrouped.is_empty() {
779 filter.insert(dp, ungrouped);
780 }
781 } else {
782 filter.insert(dp, HashSet::new());
784 }
785 }
786 filter
787 }
788
789 fn resolve_diff_path(&self, group_path: &str) -> Option<String> {
791 self.diff_data.files.iter().find_map(|f| {
792 let dp = f.target_file.trim_start_matches("b/");
793 if dp == group_path || dp.ends_with(group_path) {
794 Some(dp.to_string())
795 } else {
796 None
797 }
798 })
799 }
800
801 fn handle_key_search(&mut self, key: KeyEvent) -> Option<Command> {
803 match key.code {
804 KeyCode::Esc => {
805 self.input_mode = InputMode::Normal;
806 self.search_query.clear();
807 self.active_filter = None;
808 None
809 }
810 KeyCode::Enter => {
811 self.input_mode = InputMode::Normal;
812 self.active_filter = if self.search_query.is_empty() {
813 None
814 } else {
815 Some(self.search_query.clone())
816 };
817 self.ui_state.selected_index = 0;
818 self.adjust_scroll();
819 None
820 }
821 KeyCode::Backspace => {
822 self.search_query.pop();
823 None
824 }
825 KeyCode::Char(c) => {
826 self.search_query.push(c);
827 None
828 }
829 _ => None,
830 }
831 }
832
833 fn jump_to_match(&mut self, forward: bool) {
835 if self.active_filter.is_none() {
836 return;
837 }
838 let items = self.visible_items();
839 if items.is_empty() {
840 return;
841 }
842
843 let pattern = self.active_filter.as_ref().unwrap().to_lowercase();
844 let len = items.len();
845 let start = self.ui_state.selected_index;
846
847 for offset in 1..=len {
849 let idx = if forward {
850 (start + offset) % len
851 } else {
852 (start + len - offset) % len
853 };
854 if let VisibleItem::FileHeader { file_idx } = &items[idx] {
855 let path = &self.diff_data.files[*file_idx].target_file;
856 if path.to_lowercase().contains(&pattern) {
857 self.ui_state.selected_index = idx;
858 self.adjust_scroll();
859 return;
860 }
861 }
862 }
863 }
864
865 fn move_selection(&mut self, delta: isize, items_len: usize) {
867 let max_idx = items_len.saturating_sub(1);
868 let new_idx = if delta > 0 {
869 (self.ui_state.selected_index + delta as usize).min(max_idx)
870 } else {
871 self.ui_state.selected_index.saturating_sub((-delta) as usize)
872 };
873 self.ui_state.selected_index = new_idx;
874 self.adjust_scroll();
875 }
876
877 fn toggle_collapse(&mut self) {
879 let items = self.visible_items();
880 if let Some(item) = items.get(self.ui_state.selected_index) {
881 let node_id = match item {
882 VisibleItem::FileHeader { file_idx } => Some(NodeId::File(*file_idx)),
883 VisibleItem::HunkHeader { file_idx, hunk_idx } => {
884 Some(NodeId::Hunk(*file_idx, *hunk_idx))
885 }
886 VisibleItem::DiffLine { .. } => None, };
888
889 if let Some(id) = node_id {
890 if self.ui_state.collapsed.contains(&id) {
891 self.ui_state.collapsed.remove(&id);
892 } else {
893 self.ui_state.collapsed.insert(id);
894 }
895
896 let new_items_len = self.visible_items().len();
898 if self.ui_state.selected_index >= new_items_len {
899 self.ui_state.selected_index = new_items_len.saturating_sub(1);
900 }
901 self.adjust_scroll();
902 }
903 }
904 }
905
906 fn item_char_width(&self, item: &VisibleItem) -> usize {
908 match item {
909 VisibleItem::FileHeader { file_idx } => {
910 let file = &self.diff_data.files[*file_idx];
911 let name = if file.is_rename {
912 format!(
913 "renamed: {} -> {}",
914 file.source_file.trim_start_matches("a/"),
915 file.target_file.trim_start_matches("b/")
916 )
917 } else {
918 file.target_file.trim_start_matches("b/").to_string()
919 };
920 3 + name.len()
922 + 1
923 + format!("+{}", file.added_count).len()
924 + format!(" -{}", file.removed_count).len()
925 }
926 VisibleItem::HunkHeader { file_idx, hunk_idx } => {
927 let hunk = &self.diff_data.files[*file_idx].hunks[*hunk_idx];
928 5 + hunk.header.len()
930 }
931 VisibleItem::DiffLine {
932 file_idx,
933 hunk_idx,
934 line_idx,
935 } => {
936 let line =
937 &self.diff_data.files[*file_idx].hunks[*hunk_idx].lines[*line_idx];
938 12 + line.content.len()
940 }
941 }
942 }
943
944 pub fn item_visual_rows(&self, item: &VisibleItem, width: u16) -> usize {
946 if width == 0 {
947 return 1;
948 }
949 let char_width = self.item_char_width(item);
950 char_width.div_ceil(width as usize).max(1)
951 }
952
953 fn adjust_scroll(&mut self) {
956 let width = self.ui_state.diff_view_width.get();
957 let viewport = self.ui_state.viewport_height as usize;
958 let items = self.visible_items();
959 let selected = self.ui_state.selected_index;
960
961 if items.is_empty() || viewport == 0 {
962 self.ui_state.scroll_offset = 0;
963 return;
964 }
965
966 let scroll = self.ui_state.scroll_offset as usize;
967
968 if selected < scroll {
970 self.ui_state.scroll_offset = selected as u16;
971 return;
972 }
973
974 let mut rows = 0usize;
976 for (i, item) in items.iter().enumerate().take(selected + 1).skip(scroll) {
977 rows += self.item_visual_rows(item, width);
978 if rows > viewport && i < selected {
979 break;
980 }
981 }
982
983 if rows <= viewport {
984 return;
985 }
986
987 let selected_height = self.item_visual_rows(&items[selected], width);
989 if selected_height >= viewport {
990 self.ui_state.scroll_offset = selected as u16;
991 return;
992 }
993
994 let mut remaining = viewport - selected_height;
995 let mut new_scroll = selected;
996 for i in (0..selected).rev() {
997 let h = self.item_visual_rows(&items[i], width);
998 if h > remaining {
999 break;
1000 }
1001 remaining -= h;
1002 new_scroll = i;
1003 }
1004 self.ui_state.scroll_offset = new_scroll as u16;
1005 }
1006
1007 pub fn visible_items(&self) -> Vec<VisibleItem> {
1010 let filter_lower = self
1011 .active_filter
1012 .as_ref()
1013 .map(|f| f.to_lowercase());
1014
1015 let mut items = Vec::new();
1016 for (fi, file) in self.diff_data.files.iter().enumerate() {
1017 let file_path = file.target_file.trim_start_matches("b/");
1018
1019 if let Some(ref pattern) = filter_lower {
1021 if !file.target_file.to_lowercase().contains(pattern) {
1022 continue;
1023 }
1024 }
1025
1026 let allowed_hunks: Option<&HashSet<usize>> =
1028 self.tree_filter.as_ref().and_then(|f| f.get(file_path));
1029
1030 if self.tree_filter.is_some() && allowed_hunks.is_none() {
1032 continue;
1033 }
1034
1035 items.push(VisibleItem::FileHeader { file_idx: fi });
1036 if !self.ui_state.collapsed.contains(&NodeId::File(fi)) {
1037 for (hi, hunk) in file.hunks.iter().enumerate() {
1038 if let Some(hunk_set) = allowed_hunks {
1041 if !hunk_set.is_empty() && !hunk_set.contains(&hi) {
1042 continue;
1043 }
1044 }
1045
1046 items.push(VisibleItem::HunkHeader {
1047 file_idx: fi,
1048 hunk_idx: hi,
1049 });
1050 if !self.ui_state.collapsed.contains(&NodeId::Hunk(fi, hi)) {
1051 for (li, _line) in hunk.lines.iter().enumerate() {
1052 items.push(VisibleItem::DiffLine {
1053 file_idx: fi,
1054 hunk_idx: hi,
1055 line_idx: li,
1056 });
1057 }
1058 }
1059 }
1060 }
1061 }
1062 items
1063 }
1064
1065 pub fn view(&self, frame: &mut ratatui::Frame) {
1067 crate::ui::draw(self, frame);
1068 }
1069}