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 None
530 }
531 KeyCode::Char('k') | KeyCode::Up => {
532 ts.key_up();
533 None
534 }
535 KeyCode::Left => {
536 ts.key_left();
537 None
538 }
539 KeyCode::Right => {
540 ts.key_right();
541 None
542 }
543 KeyCode::Enter => {
544 let selected = ts.selected().to_vec();
545 drop(ts); if let Some(last) = selected.last() {
547 match last {
548 TreeNodeId::File(path) => {
549 self.select_tree_file(path);
550 }
551 TreeNodeId::Group(gi) => {
552 self.select_tree_group(*gi);
553 }
554 }
555 }
556 None
557 }
558 KeyCode::Char('g') => {
559 ts.select_first();
560 None
561 }
562 KeyCode::Char('G') => {
563 ts.select_last();
564 None
565 }
566 _ => None,
567 }
568 }
569
570 fn handle_key_diff(&mut self, key: KeyEvent) -> Option<Command> {
572 let items_len = self.visible_items().len();
573 if items_len == 0 {
574 return None;
575 }
576
577 match key.code {
578 KeyCode::Char('n') => {
580 self.jump_to_match(true);
581 None
582 }
583 KeyCode::Char('N') => {
584 self.jump_to_match(false);
585 None
586 }
587
588 KeyCode::Char('j') | KeyCode::Down => {
590 self.move_selection(1, items_len);
591 None
592 }
593 KeyCode::Char('k') | KeyCode::Up => {
594 self.move_selection(-1, items_len);
595 None
596 }
597 KeyCode::Char('g') => {
598 self.ui_state.selected_index = 0;
599 self.adjust_scroll();
600 None
601 }
602 KeyCode::Char('G') => {
603 self.ui_state.selected_index = items_len.saturating_sub(1);
604 self.adjust_scroll();
605 None
606 }
607 KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
608 let half_page = (self.ui_state.viewport_height / 2) as usize;
609 self.move_selection(half_page as isize, items_len);
610 None
611 }
612 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
613 let half_page = (self.ui_state.viewport_height / 2) as usize;
614 self.move_selection(-(half_page as isize), items_len);
615 None
616 }
617
618 KeyCode::Enter => {
620 self.toggle_collapse();
621 None
622 }
623
624 _ => None,
625 }
626 }
627
628 fn select_tree_file(&mut self, path: &str) {
631 let filter = self.hunk_filter_for_file(path);
632 self.tree_filter = Some(filter);
634 let items = self.visible_items();
636 let target_idx = items.iter().position(|item| {
637 if let VisibleItem::FileHeader { file_idx } = item {
638 self.diff_data.files[*file_idx]
639 .target_file
640 .trim_start_matches("b/")
641 == path
642 } else {
643 false
644 }
645 });
646 self.ui_state.selected_index = target_idx.unwrap_or(0);
647 self.ui_state.scroll_offset = self.ui_state.selected_index as u16;
649 }
650
651 fn select_tree_group(&mut self, group_idx: usize) {
653 let filter = self.hunk_filter_for_group(group_idx);
654 if filter.is_empty() {
655 self.tree_state.borrow_mut().toggle_selected();
656 return;
657 }
658 if self.tree_filter.as_ref() == Some(&filter) {
660 self.tree_filter = None;
661 } else {
662 self.tree_filter = Some(filter);
663 }
664 self.ui_state.selected_index = 0;
665 self.ui_state.scroll_offset = 0;
666 }
667
668 fn hunk_filter_for_file(&self, path: &str) -> HunkFilter {
671 if let Some(groups) = &self.semantic_groups {
672 for (gi, group) in groups.iter().enumerate() {
673 let has_file = group.changes().iter().any(|c| {
674 c.file == path || path.ends_with(c.file.as_str()) || c.file.ends_with(path)
675 });
676 if has_file {
677 return self.hunk_filter_for_group(gi);
678 }
679 }
680 return self.hunk_filter_for_other();
682 }
683 let mut filter = HunkFilter::new();
685 filter.insert(path.to_string(), HashSet::new());
686 filter
687 }
688
689 fn hunk_filter_for_group(&self, group_idx: usize) -> HunkFilter {
691 if let Some(groups) = &self.semantic_groups {
692 if let Some(group) = groups.get(group_idx) {
693 let mut filter = HunkFilter::new();
694 for change in &group.changes() {
695 if let Some(diff_path) = self.resolve_diff_path(&change.file) {
697 let hunk_set: HashSet<usize> = change.hunks.iter().copied().collect();
698 filter
699 .entry(diff_path)
700 .or_default()
701 .extend(hunk_set.iter());
702 }
703 }
704 return filter;
705 }
706 if group_idx >= groups.len() {
708 return self.hunk_filter_for_other();
709 }
710 }
711 HunkFilter::new()
712 }
713
714 fn hunk_filter_for_other(&self) -> HunkFilter {
716 let groups = match &self.semantic_groups {
717 Some(g) => g,
718 None => return HunkFilter::new(),
719 };
720
721 let mut grouped: HashMap<String, HashSet<usize>> = HashMap::new();
723 for group in groups {
724 for change in &group.changes() {
725 if let Some(dp) = self.resolve_diff_path(&change.file) {
726 grouped.entry(dp).or_default().extend(change.hunks.iter());
727 }
728 }
729 }
730
731 let mut filter = HunkFilter::new();
733 for file in &self.diff_data.files {
734 let dp = file.target_file.trim_start_matches("b/").to_string();
735 if let Some(grouped_hunks) = grouped.get(&dp) {
736 if grouped_hunks.is_empty() {
738 continue;
739 }
740 let ungrouped: HashSet<usize> = (0..file.hunks.len())
741 .filter(|hi| !grouped_hunks.contains(hi))
742 .collect();
743 if !ungrouped.is_empty() {
744 filter.insert(dp, ungrouped);
745 }
746 } else {
747 filter.insert(dp, HashSet::new());
749 }
750 }
751 filter
752 }
753
754 fn resolve_diff_path(&self, group_path: &str) -> Option<String> {
756 self.diff_data.files.iter().find_map(|f| {
757 let dp = f.target_file.trim_start_matches("b/");
758 if dp == group_path || dp.ends_with(group_path) {
759 Some(dp.to_string())
760 } else {
761 None
762 }
763 })
764 }
765
766 fn handle_key_search(&mut self, key: KeyEvent) -> Option<Command> {
768 match key.code {
769 KeyCode::Esc => {
770 self.input_mode = InputMode::Normal;
771 self.search_query.clear();
772 self.active_filter = None;
773 None
774 }
775 KeyCode::Enter => {
776 self.input_mode = InputMode::Normal;
777 self.active_filter = if self.search_query.is_empty() {
778 None
779 } else {
780 Some(self.search_query.clone())
781 };
782 self.ui_state.selected_index = 0;
783 self.adjust_scroll();
784 None
785 }
786 KeyCode::Backspace => {
787 self.search_query.pop();
788 None
789 }
790 KeyCode::Char(c) => {
791 self.search_query.push(c);
792 None
793 }
794 _ => None,
795 }
796 }
797
798 fn jump_to_match(&mut self, forward: bool) {
800 if self.active_filter.is_none() {
801 return;
802 }
803 let items = self.visible_items();
804 if items.is_empty() {
805 return;
806 }
807
808 let pattern = self.active_filter.as_ref().unwrap().to_lowercase();
809 let len = items.len();
810 let start = self.ui_state.selected_index;
811
812 for offset in 1..=len {
814 let idx = if forward {
815 (start + offset) % len
816 } else {
817 (start + len - offset) % len
818 };
819 if let VisibleItem::FileHeader { file_idx } = &items[idx] {
820 let path = &self.diff_data.files[*file_idx].target_file;
821 if path.to_lowercase().contains(&pattern) {
822 self.ui_state.selected_index = idx;
823 self.adjust_scroll();
824 return;
825 }
826 }
827 }
828 }
829
830 fn move_selection(&mut self, delta: isize, items_len: usize) {
832 let max_idx = items_len.saturating_sub(1);
833 let new_idx = if delta > 0 {
834 (self.ui_state.selected_index + delta as usize).min(max_idx)
835 } else {
836 self.ui_state.selected_index.saturating_sub((-delta) as usize)
837 };
838 self.ui_state.selected_index = new_idx;
839 self.adjust_scroll();
840 }
841
842 fn toggle_collapse(&mut self) {
844 let items = self.visible_items();
845 if let Some(item) = items.get(self.ui_state.selected_index) {
846 let node_id = match item {
847 VisibleItem::FileHeader { file_idx } => Some(NodeId::File(*file_idx)),
848 VisibleItem::HunkHeader { file_idx, hunk_idx } => {
849 Some(NodeId::Hunk(*file_idx, *hunk_idx))
850 }
851 VisibleItem::DiffLine { .. } => None, };
853
854 if let Some(id) = node_id {
855 if self.ui_state.collapsed.contains(&id) {
856 self.ui_state.collapsed.remove(&id);
857 } else {
858 self.ui_state.collapsed.insert(id);
859 }
860
861 let new_items_len = self.visible_items().len();
863 if self.ui_state.selected_index >= new_items_len {
864 self.ui_state.selected_index = new_items_len.saturating_sub(1);
865 }
866 self.adjust_scroll();
867 }
868 }
869 }
870
871 fn item_char_width(&self, item: &VisibleItem) -> usize {
873 match item {
874 VisibleItem::FileHeader { file_idx } => {
875 let file = &self.diff_data.files[*file_idx];
876 let name = if file.is_rename {
877 format!(
878 "renamed: {} -> {}",
879 file.source_file.trim_start_matches("a/"),
880 file.target_file.trim_start_matches("b/")
881 )
882 } else {
883 file.target_file.trim_start_matches("b/").to_string()
884 };
885 3 + name.len()
887 + 1
888 + format!("+{}", file.added_count).len()
889 + format!(" -{}", file.removed_count).len()
890 }
891 VisibleItem::HunkHeader { file_idx, hunk_idx } => {
892 let hunk = &self.diff_data.files[*file_idx].hunks[*hunk_idx];
893 5 + hunk.header.len()
895 }
896 VisibleItem::DiffLine {
897 file_idx,
898 hunk_idx,
899 line_idx,
900 } => {
901 let line =
902 &self.diff_data.files[*file_idx].hunks[*hunk_idx].lines[*line_idx];
903 12 + line.content.len()
905 }
906 }
907 }
908
909 pub fn item_visual_rows(&self, item: &VisibleItem, width: u16) -> usize {
911 if width == 0 {
912 return 1;
913 }
914 let char_width = self.item_char_width(item);
915 char_width.div_ceil(width as usize).max(1)
916 }
917
918 fn adjust_scroll(&mut self) {
921 let width = self.ui_state.diff_view_width.get();
922 let viewport = self.ui_state.viewport_height as usize;
923 let items = self.visible_items();
924 let selected = self.ui_state.selected_index;
925
926 if items.is_empty() || viewport == 0 {
927 self.ui_state.scroll_offset = 0;
928 return;
929 }
930
931 let scroll = self.ui_state.scroll_offset as usize;
932
933 if selected < scroll {
935 self.ui_state.scroll_offset = selected as u16;
936 return;
937 }
938
939 let mut rows = 0usize;
941 for (i, item) in items.iter().enumerate().take(selected + 1).skip(scroll) {
942 rows += self.item_visual_rows(item, width);
943 if rows > viewport && i < selected {
944 break;
945 }
946 }
947
948 if rows <= viewport {
949 return;
950 }
951
952 let selected_height = self.item_visual_rows(&items[selected], width);
954 if selected_height >= viewport {
955 self.ui_state.scroll_offset = selected as u16;
956 return;
957 }
958
959 let mut remaining = viewport - selected_height;
960 let mut new_scroll = selected;
961 for i in (0..selected).rev() {
962 let h = self.item_visual_rows(&items[i], width);
963 if h > remaining {
964 break;
965 }
966 remaining -= h;
967 new_scroll = i;
968 }
969 self.ui_state.scroll_offset = new_scroll as u16;
970 }
971
972 pub fn visible_items(&self) -> Vec<VisibleItem> {
975 let filter_lower = self
976 .active_filter
977 .as_ref()
978 .map(|f| f.to_lowercase());
979
980 let mut items = Vec::new();
981 for (fi, file) in self.diff_data.files.iter().enumerate() {
982 let file_path = file.target_file.trim_start_matches("b/");
983
984 if let Some(ref pattern) = filter_lower {
986 if !file.target_file.to_lowercase().contains(pattern) {
987 continue;
988 }
989 }
990
991 let allowed_hunks: Option<&HashSet<usize>> =
993 self.tree_filter.as_ref().and_then(|f| f.get(file_path));
994
995 if self.tree_filter.is_some() && allowed_hunks.is_none() {
997 continue;
998 }
999
1000 items.push(VisibleItem::FileHeader { file_idx: fi });
1001 if !self.ui_state.collapsed.contains(&NodeId::File(fi)) {
1002 for (hi, hunk) in file.hunks.iter().enumerate() {
1003 if let Some(hunk_set) = allowed_hunks {
1006 if !hunk_set.is_empty() && !hunk_set.contains(&hi) {
1007 continue;
1008 }
1009 }
1010
1011 items.push(VisibleItem::HunkHeader {
1012 file_idx: fi,
1013 hunk_idx: hi,
1014 });
1015 if !self.ui_state.collapsed.contains(&NodeId::Hunk(fi, hi)) {
1016 for (li, _line) in hunk.lines.iter().enumerate() {
1017 items.push(VisibleItem::DiffLine {
1018 file_idx: fi,
1019 hunk_idx: hi,
1020 line_idx: li,
1021 });
1022 }
1023 }
1024 }
1025 }
1026 }
1027 items
1028 }
1029
1030 pub fn view(&self, frame: &mut ratatui::Frame) {
1032 crate::ui::draw(self, frame);
1033 }
1034}