1use crate::diff::DiffData;
2use crate::grouper::llm::LlmBackend;
3use crate::grouper::{GroupingStatus, SemanticGroup};
4use crate::highlight::HighlightCache;
5use crate::preview::mermaid::{ImageSupport, MermaidCache};
6use crate::theme::Theme;
7use crate::ui::file_tree::TreeNodeId;
8use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
9use std::cell::{Cell, RefCell};
10use std::collections::{HashMap, HashSet};
11use tokio::sync::mpsc;
12use tui_tree_widget::TreeState;
13
14pub type HunkFilter = HashMap<String, HashSet<usize>>;
17
18#[derive(Debug, Clone, PartialEq)]
20pub enum InputMode {
21 Normal,
22 Search,
23 Help,
24 Settings,
25}
26
27#[derive(Debug, Clone, PartialEq)]
29pub enum FocusedPanel {
30 FileTree,
31 DiffView,
32}
33
34#[derive(Debug)]
36pub enum Message {
37 KeyPress(KeyEvent),
38 Resize(u16, u16),
39 RefreshSignal,
40 DebouncedRefresh,
41 DiffParsed(DiffData, String), GroupingComplete(Vec<SemanticGroup>, u64), GroupingFailed(String),
44 IncrementalGroupingComplete(
45 Vec<SemanticGroup>,
46 crate::grouper::DiffDelta,
47 HashMap<String, u64>,
48 u64, String, ),
51 MermaidReady,
53}
54
55
56#[allow(dead_code)]
58pub enum Command {
59 SpawnDiffParse { git_diff_args: Vec<String> },
60 SpawnGrouping {
61 backend: LlmBackend,
62 model: String,
63 summaries: String,
64 diff_hash: u64,
65 head_commit: Option<String>,
66 file_hashes: HashMap<String, u64>,
67 },
68 SpawnIncrementalGrouping {
69 backend: LlmBackend,
70 model: String,
71 summaries: String,
72 diff_hash: u64,
73 head_commit: String,
74 file_hashes: HashMap<String, u64>,
75 delta: crate::grouper::DiffDelta,
76 },
77 Quit,
78}
79
80#[derive(Debug, Clone, Hash, Eq, PartialEq)]
82pub enum NodeId {
83 File(usize),
84 Hunk(usize, usize),
85}
86
87pub struct UiState {
89 pub selected_index: usize,
90 pub scroll_offset: u16,
91 pub collapsed: HashSet<NodeId>,
92 pub viewport_height: u16,
94 pub diff_view_width: Cell<u16>,
96 pub preview_scroll: usize,
98}
99
100#[derive(Debug, Clone)]
102pub enum VisibleItem {
103 FileHeader { file_idx: usize },
104 HunkHeader { file_idx: usize, hunk_idx: usize },
105 DiffLine { file_idx: usize, hunk_idx: usize, line_idx: usize },
106}
107
108pub struct App {
110 pub diff_data: DiffData,
111 pub ui_state: UiState,
112 pub highlight_cache: HighlightCache,
113 #[allow(dead_code)]
114 pub should_quit: bool,
115 pub event_tx: Option<mpsc::Sender<Message>>,
117 pub debounce_handle: Option<tokio::task::JoinHandle<()>>,
119 pub input_mode: InputMode,
121 pub search_query: String,
123 pub active_filter: Option<String>,
125 pub semantic_groups: Option<Vec<SemanticGroup>>,
127 pub grouping_status: GroupingStatus,
129 pub grouping_handle: Option<tokio::task::JoinHandle<()>>,
131 pub llm_backend: Option<LlmBackend>,
133 pub llm_model: String,
135 pub focused_panel: FocusedPanel,
137 pub tree_state: RefCell<TreeState<TreeNodeId>>,
139 pub tree_filter: Option<HunkFilter>,
142 pub theme: Theme,
144 pub previous_head: Option<String>,
146 pub previous_file_hashes: HashMap<String, u64>,
148 pub git_diff_args: Vec<String>,
150 pub preview_mode: bool,
152 pub image_support: ImageSupport,
154 pub mermaid_cache: Option<MermaidCache>,
156}
157
158impl App {
159 pub fn new(diff_data: DiffData, config: &crate::config::Config, git_diff_args: Vec<String>) -> Self {
161 let theme = Theme::from_mode(config.theme_mode);
162 let highlight_cache = HighlightCache::new(&diff_data, theme.syntect_theme);
163 let image_support = crate::preview::mermaid::detect_image_support();
164 let mermaid_cache = match &image_support {
165 ImageSupport::Supported(_) => Some(MermaidCache::new()),
166 _ => None,
167 };
168 Self {
169 diff_data,
170 ui_state: UiState {
171 selected_index: 0,
172 scroll_offset: 0,
173 collapsed: HashSet::new(),
174 viewport_height: 24, diff_view_width: Cell::new(80),
176 preview_scroll: 0,
177 },
178 highlight_cache,
179 should_quit: false,
180 event_tx: None,
181 debounce_handle: None,
182 input_mode: InputMode::Normal,
183 search_query: String::new(),
184 active_filter: None,
185 semantic_groups: None,
186 grouping_status: GroupingStatus::Idle,
187 grouping_handle: None,
188 llm_backend: config.detect_backend(),
189 llm_model: config
190 .detect_backend()
191 .map(|b| config.model_for_backend(b).to_string())
192 .unwrap_or_default(),
193 focused_panel: FocusedPanel::DiffView,
194 tree_state: RefCell::new(TreeState::default()),
195 tree_filter: None,
196 theme,
197 previous_head: None,
198 previous_file_hashes: HashMap::new(),
199 git_diff_args,
200 preview_mode: false,
201 image_support,
202 mermaid_cache,
203 }
204 }
205
206 pub fn update(&mut self, msg: Message) -> Option<Command> {
208 match msg {
209 Message::KeyPress(key) => self.handle_key(key),
210 Message::Resize(_w, h) => {
211 self.ui_state.viewport_height = h.saturating_sub(1);
212 None
213 }
214 Message::RefreshSignal => {
215 if let Some(handle) = self.debounce_handle.take() {
217 handle.abort();
218 }
219 if let Some(tx) = &self.event_tx {
221 let tx = tx.clone();
222 self.debounce_handle = Some(tokio::spawn(async move {
223 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
224 let _ = tx.send(Message::DebouncedRefresh).await;
225 }));
226 }
227 None
228 }
229 Message::DebouncedRefresh => {
230 self.debounce_handle = None;
231 Some(Command::SpawnDiffParse {
232 git_diff_args: self.git_diff_args.clone(),
233 })
234 }
235 Message::DiffParsed(new_data, raw_diff) => {
236 self.apply_new_diff_data(new_data);
237 let hash = crate::cache::diff_hash(&raw_diff);
238 let current_head = crate::cache::get_head_commit();
239
240 if let Some(cached) = crate::cache::load(hash) {
242 let mut groups = cached;
243 crate::grouper::normalize_hunk_indices(&mut groups, &self.diff_data);
244 self.semantic_groups = Some(groups);
245 self.grouping_status = GroupingStatus::Done;
246 self.grouping_handle = None;
247 if let Some(ref head) = current_head {
249 self.previous_head = Some(head.clone());
250 }
251 self.previous_file_hashes =
252 crate::grouper::compute_all_file_hashes(&self.diff_data);
253 return None;
254 }
255
256 let can_incremental = current_head.is_some()
258 && self.previous_head.as_ref() == current_head.as_ref()
259 && self.semantic_groups.is_some()
260 && !self.previous_file_hashes.is_empty();
261
262 if can_incremental {
263 let new_hashes = crate::grouper::compute_all_file_hashes(&self.diff_data);
264 let delta =
265 crate::grouper::compute_diff_delta(&new_hashes, &self.previous_file_hashes);
266
267 if !delta.has_changes() {
268 self.grouping_status = GroupingStatus::Done;
270 return None;
271 }
272
273 if delta.is_only_removals() {
274 let mut groups = self.semantic_groups.clone().unwrap_or_default();
276 crate::grouper::remove_files_from_groups(&mut groups, &delta.removed_files);
277 crate::grouper::normalize_hunk_indices(&mut groups, &self.diff_data);
278 self.semantic_groups = Some(groups);
279 self.grouping_status = GroupingStatus::Done;
280 self.previous_file_hashes = new_hashes.clone();
281 if let Some(ref head) = current_head {
283 crate::cache::save_with_state(
284 hash,
285 self.semantic_groups.as_ref().unwrap(),
286 Some(head),
287 &new_hashes,
288 );
289 }
290 return None;
291 }
292
293 if let Some(backend) = self.llm_backend {
295 if let Some(handle) = self.grouping_handle.take() {
296 handle.abort();
297 }
298 self.grouping_status = GroupingStatus::Loading;
299 let existing = self.semantic_groups.as_ref().unwrap();
300 let summaries = crate::grouper::incremental_hunk_summaries(
301 &self.diff_data,
302 &delta,
303 existing,
304 );
305 tracing::info!(
306 new = delta.new_files.len(),
307 modified = delta.modified_files.len(),
308 removed = delta.removed_files.len(),
309 unchanged = delta.unchanged_files.len(),
310 "Incremental grouping"
311 );
312 return Some(Command::SpawnIncrementalGrouping {
313 backend,
314 model: self.llm_model.clone(),
315 summaries,
316 diff_hash: hash,
317 head_commit: current_head.unwrap(),
318 file_hashes: new_hashes,
319 delta,
320 });
321 }
322 }
323
324 if let Some(backend) = self.llm_backend {
326 if let Some(handle) = self.grouping_handle.take() {
328 handle.abort();
329 }
330 self.grouping_status = GroupingStatus::Loading;
331 let summaries = crate::grouper::hunk_summaries(&self.diff_data);
332 let file_hashes = crate::grouper::compute_all_file_hashes(&self.diff_data);
333 Some(Command::SpawnGrouping {
334 backend,
335 model: self.llm_model.clone(),
336 summaries,
337 diff_hash: hash,
338 head_commit: current_head,
339 file_hashes,
340 })
341 } else {
342 self.grouping_status = GroupingStatus::Idle;
343 None
344 }
345 }
346 Message::GroupingComplete(groups, diff_hash) => {
347 let mut groups = groups;
348 crate::grouper::normalize_hunk_indices(&mut groups, &self.diff_data);
349 let current_head = crate::cache::get_head_commit();
351 let file_hashes = crate::grouper::compute_all_file_hashes(&self.diff_data);
352 crate::cache::save_with_state(
354 diff_hash,
355 &groups,
356 current_head.as_deref(),
357 &file_hashes,
358 );
359 self.previous_head = current_head;
360 self.previous_file_hashes = file_hashes;
361 self.semantic_groups = Some(groups);
362 self.grouping_status = GroupingStatus::Done;
363 self.grouping_handle = None;
364 let mut ts = self.tree_state.borrow_mut();
366 *ts = TreeState::default();
367 ts.select_first();
368 drop(ts);
369 self.tree_filter = None;
371 None
372 }
373 Message::IncrementalGroupingComplete(new_assignments, delta, file_hashes, diff_hash, head_commit) => {
374 let existing = self.semantic_groups.as_ref().cloned().unwrap_or_default();
375 let mut merged =
376 crate::grouper::merge_groups(&existing, &new_assignments, &delta);
377 crate::grouper::normalize_hunk_indices(&mut merged, &self.diff_data);
378 crate::cache::save_with_state(
380 diff_hash,
381 &merged,
382 Some(&head_commit),
383 &file_hashes,
384 );
385 self.semantic_groups = Some(merged);
386 self.grouping_status = GroupingStatus::Done;
387 self.grouping_handle = None;
388 self.previous_file_hashes = file_hashes;
389 self.previous_head = Some(head_commit);
390 let mut ts = self.tree_state.borrow_mut();
392 *ts = TreeState::default();
393 ts.select_first();
394 drop(ts);
395 self.tree_filter = None;
396 None
397 }
398 Message::GroupingFailed(err) => {
399 tracing::warn!("Grouping failed: {}", err);
400 self.grouping_status = GroupingStatus::Error(err);
401 self.grouping_handle = None;
402 None }
404 Message::MermaidReady => None, }
406 }
407
408 fn apply_new_diff_data(&mut self, new_data: DiffData) {
410 let mut collapsed_files: HashSet<String> = HashSet::new();
412 let mut collapsed_hunks: HashSet<(String, usize)> = HashSet::new();
413
414 for node in &self.ui_state.collapsed {
415 match node {
416 NodeId::File(fi) => {
417 if let Some(file) = self.diff_data.files.get(*fi) {
418 collapsed_files.insert(file.target_file.clone());
419 }
420 }
421 NodeId::Hunk(fi, hi) => {
422 if let Some(file) = self.diff_data.files.get(*fi) {
423 collapsed_hunks.insert((file.target_file.clone(), *hi));
424 }
425 }
426 }
427 }
428
429 let selected_path = self.selected_file_path();
431
432 self.diff_data = new_data;
434 self.highlight_cache = HighlightCache::new(&self.diff_data, self.theme.syntect_theme);
435
436 self.ui_state.collapsed.clear();
438 for (fi, file) in self.diff_data.files.iter().enumerate() {
439 if collapsed_files.contains(&file.target_file) {
440 self.ui_state.collapsed.insert(NodeId::File(fi));
441 }
442 for (hi, _) in file.hunks.iter().enumerate() {
443 if collapsed_hunks.contains(&(file.target_file.clone(), hi)) {
444 self.ui_state.collapsed.insert(NodeId::Hunk(fi, hi));
445 }
446 }
447 }
448
449 if let Some(path) = selected_path {
451 let items = self.visible_items();
452 let restored = items.iter().position(|item| {
453 if let VisibleItem::FileHeader { file_idx } = item {
454 self.diff_data.files[*file_idx].target_file == path
455 } else {
456 false
457 }
458 });
459 if let Some(idx) = restored {
460 self.ui_state.selected_index = idx;
461 } else {
462 self.ui_state.selected_index = self
463 .ui_state
464 .selected_index
465 .min(items.len().saturating_sub(1));
466 }
467 } else {
468 let items_len = self.visible_items().len();
469 self.ui_state.selected_index = self
470 .ui_state
471 .selected_index
472 .min(items_len.saturating_sub(1));
473 }
474
475 self.adjust_scroll();
476 }
477
478 fn selected_file_path(&self) -> Option<String> {
480 let items = self.visible_items();
481 let item = items.get(self.ui_state.selected_index)?;
482 let fi = match item {
483 VisibleItem::FileHeader { file_idx } => *file_idx,
484 VisibleItem::HunkHeader { file_idx, .. } => *file_idx,
485 VisibleItem::DiffLine { file_idx, .. } => *file_idx,
486 };
487 self.diff_data.files.get(fi).map(|f| f.target_file.clone())
488 }
489
490 fn handle_key(&mut self, key: KeyEvent) -> Option<Command> {
492 match self.input_mode {
493 InputMode::Normal => self.handle_key_normal(key),
494 InputMode::Search => self.handle_key_search(key),
495 InputMode::Help => {
496 self.input_mode = InputMode::Normal;
498 None
499 }
500 InputMode::Settings => self.handle_key_settings(key),
501 }
502 }
503
504 fn handle_key_settings(&mut self, key: KeyEvent) -> Option<Command> {
506 match key.code {
507 KeyCode::Char('d') => {
508 self.toggle_theme();
509 None
510 }
511 KeyCode::Esc => {
512 self.input_mode = InputMode::Normal;
513 None
514 }
515 _ => None,
516 }
517 }
518
519 pub fn toggle_theme(&mut self) {
521 let new_theme = if self.theme.syntect_theme.contains("dark") {
522 crate::theme::Theme::light()
523 } else {
524 crate::theme::Theme::dark()
525 };
526 self.theme = new_theme;
527 self.highlight_cache = HighlightCache::new(&self.diff_data, self.theme.syntect_theme);
528 }
529
530 fn handle_key_normal(&mut self, key: KeyEvent) -> Option<Command> {
532 match key.code {
534 KeyCode::Char('q') => return Some(Command::Quit),
535 KeyCode::Char('?') => {
536 self.input_mode = InputMode::Help;
537 return None;
538 }
539 KeyCode::Char(',') => {
540 self.input_mode = InputMode::Settings;
541 return None;
542 }
543 KeyCode::Tab => {
544 self.focused_panel = match self.focused_panel {
545 FocusedPanel::FileTree => FocusedPanel::DiffView,
546 FocusedPanel::DiffView => FocusedPanel::FileTree,
547 };
548 return None;
549 }
550 KeyCode::Esc => {
551 if self.tree_filter.is_some() || self.active_filter.is_some() {
552 self.tree_filter = None;
553 self.active_filter = None;
554 self.ui_state.selected_index = 0;
555 self.adjust_scroll();
556 return None;
557 } else {
558 return Some(Command::Quit);
559 }
560 }
561 KeyCode::Char('/') => {
562 self.input_mode = InputMode::Search;
563 self.search_query.clear();
564 return None;
565 }
566 KeyCode::Char('p') => {
567 if crate::ui::preview_view::is_current_file_markdown(self) {
568 self.preview_mode = !self.preview_mode;
569 if self.preview_mode {
570 self.ui_state.preview_scroll = 0;
571 }
572 }
573 return None;
574 }
575 _ => {}
576 }
577
578 match self.focused_panel {
580 FocusedPanel::FileTree => self.handle_key_tree(key),
581 FocusedPanel::DiffView => self.handle_key_diff(key),
582 }
583 }
584
585 fn handle_key_tree(&mut self, key: KeyEvent) -> Option<Command> {
587 let mut ts = self.tree_state.borrow_mut();
588 match key.code {
589 KeyCode::Char('j') | KeyCode::Down => {
590 ts.key_down();
591 }
592 KeyCode::Char('k') | KeyCode::Up => {
593 ts.key_up();
594 }
595 KeyCode::Left => {
596 ts.key_left();
597 }
598 KeyCode::Right => {
599 ts.key_right();
600 }
601 KeyCode::Enter => {
602 ts.toggle_selected();
603 }
604 KeyCode::Char('g') => {
605 ts.select_first();
606 }
607 KeyCode::Char('G') => {
608 ts.select_last();
609 }
610 _ => return None,
611 }
612
613 let selected = ts.selected().to_vec();
615 drop(ts); self.apply_tree_selection(&selected);
617 None
618 }
619
620 fn apply_tree_selection(&mut self, selected: &[TreeNodeId]) {
622 match selected.last() {
623 Some(TreeNodeId::File(group_idx, path)) => {
624 self.select_tree_file(path, *group_idx);
625 }
626 Some(TreeNodeId::Group(gi)) => {
627 self.select_tree_group(*gi);
628 }
629 None => {}
630 }
631 }
632
633 fn handle_key_diff(&mut self, key: KeyEvent) -> Option<Command> {
635 if self.preview_mode {
637 return self.handle_key_preview(key);
638 }
639
640 let items_len = self.visible_items().len();
641 if items_len == 0 {
642 return None;
643 }
644
645 match key.code {
646 KeyCode::Char('n') => {
648 self.jump_to_match(true);
649 None
650 }
651 KeyCode::Char('N') => {
652 self.jump_to_match(false);
653 None
654 }
655
656 KeyCode::Char('j') | KeyCode::Down => {
658 self.move_selection(1, items_len);
659 None
660 }
661 KeyCode::Char('k') | KeyCode::Up => {
662 self.move_selection(-1, items_len);
663 None
664 }
665 KeyCode::Char('g') => {
666 self.ui_state.selected_index = 0;
667 self.adjust_scroll();
668 None
669 }
670 KeyCode::Char('G') => {
671 self.ui_state.selected_index = items_len.saturating_sub(1);
672 self.adjust_scroll();
673 None
674 }
675 KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
676 let half_page = (self.ui_state.viewport_height / 2) as usize;
677 self.move_selection(half_page as isize, items_len);
678 None
679 }
680 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
681 let half_page = (self.ui_state.viewport_height / 2) as usize;
682 self.move_selection(-(half_page as isize), items_len);
683 None
684 }
685
686 KeyCode::Enter => {
688 self.toggle_collapse();
689 None
690 }
691
692 _ => None,
693 }
694 }
695
696 fn select_tree_file(&mut self, path: &str, group_idx: Option<usize>) {
699 let filter = self.hunk_filter_for_file(path, group_idx);
700 self.tree_filter = Some(filter);
702 let items = self.visible_items();
704 let target_idx = items.iter().position(|item| {
705 if let VisibleItem::FileHeader { file_idx } = item {
706 self.diff_data.files[*file_idx]
707 .target_file
708 .trim_start_matches("b/")
709 == path
710 } else {
711 false
712 }
713 });
714 self.ui_state.selected_index = target_idx.unwrap_or(0);
715 self.ui_state.scroll_offset = self.ui_state.selected_index as u16;
717 }
718
719 fn select_tree_group(&mut self, group_idx: usize) {
721 let filter = self.hunk_filter_for_group(group_idx);
722 if filter.is_empty() {
723 return;
724 }
725 self.tree_filter = Some(filter);
726 self.ui_state.selected_index = 0;
727 self.ui_state.scroll_offset = 0;
728 }
729
730 fn hunk_filter_for_file(&self, path: &str, group_idx: Option<usize>) -> HunkFilter {
733 if let Some(groups) = &self.semantic_groups {
734 if let Some(gi) = group_idx {
735 if gi >= groups.len() {
736 return self.hunk_filter_for_file_in_other(path);
738 }
739 if let Some(group) = groups.get(gi) {
740 if let Some(filter) = self.hunk_filter_for_file_in_group(path, group) {
741 return filter;
742 }
743 }
744 }
745 for group in groups.iter() {
748 if let Some(filter) = self.hunk_filter_for_file_in_group(path, group) {
749 return filter;
750 }
751 }
752 return self.hunk_filter_for_file_in_other(path);
753 }
754 let mut filter = HunkFilter::new();
756 filter.insert(path.to_string(), HashSet::new());
757 filter
758 }
759
760 fn hunk_filter_for_file_in_group(
762 &self,
763 path: &str,
764 group: &crate::grouper::SemanticGroup,
765 ) -> Option<HunkFilter> {
766 for change in &group.changes() {
767 if let Some(diff_path) = self.resolve_diff_path(&change.file) {
768 if diff_path == path {
769 let mut filter = HunkFilter::new();
770 let hunk_set: HashSet<usize> = change.hunks.iter().copied().collect();
771 filter.insert(diff_path, hunk_set);
772 return Some(filter);
773 }
774 }
775 }
776 None
777 }
778
779 fn hunk_filter_for_file_in_other(&self, path: &str) -> HunkFilter {
781 let other = self.hunk_filter_for_other();
782 let mut filter = HunkFilter::new();
783 if let Some(hunk_set) = other.get(path) {
784 filter.insert(path.to_string(), hunk_set.clone());
785 } else {
786 filter.insert(path.to_string(), HashSet::new());
787 }
788 filter
789 }
790
791 fn hunk_filter_for_group(&self, group_idx: usize) -> HunkFilter {
793 if let Some(groups) = &self.semantic_groups {
794 if let Some(group) = groups.get(group_idx) {
795 let mut filter = HunkFilter::new();
796 for change in &group.changes() {
797 if let Some(diff_path) = self.resolve_diff_path(&change.file) {
799 let hunk_set: HashSet<usize> = change.hunks.iter().copied().collect();
800 filter
801 .entry(diff_path)
802 .or_default()
803 .extend(hunk_set.iter());
804 }
805 }
806 return filter;
807 }
808 if group_idx >= groups.len() {
810 return self.hunk_filter_for_other();
811 }
812 }
813 HunkFilter::new()
814 }
815
816 fn hunk_filter_for_other(&self) -> HunkFilter {
818 let groups = match &self.semantic_groups {
819 Some(g) => g,
820 None => return HunkFilter::new(),
821 };
822
823 let mut grouped: HashMap<String, HashSet<usize>> = HashMap::new();
825 for group in groups {
826 for change in &group.changes() {
827 if let Some(dp) = self.resolve_diff_path(&change.file) {
828 grouped.entry(dp).or_default().extend(change.hunks.iter());
829 }
830 }
831 }
832
833 let mut filter = HunkFilter::new();
835 for file in &self.diff_data.files {
836 let dp = file.target_file.trim_start_matches("b/").to_string();
837 if let Some(grouped_hunks) = grouped.get(&dp) {
838 if grouped_hunks.is_empty() {
840 continue;
841 }
842 let ungrouped: HashSet<usize> = (0..file.hunks.len())
843 .filter(|hi| !grouped_hunks.contains(hi))
844 .collect();
845 if !ungrouped.is_empty() {
846 filter.insert(dp, ungrouped);
847 }
848 } else {
849 filter.insert(dp, HashSet::new());
851 }
852 }
853 filter
854 }
855
856 fn resolve_diff_path(&self, group_path: &str) -> Option<String> {
858 self.diff_data.files.iter().find_map(|f| {
859 let dp = f.target_file.trim_start_matches("b/");
860 if dp == group_path || dp.ends_with(group_path) {
861 Some(dp.to_string())
862 } else {
863 None
864 }
865 })
866 }
867
868 fn handle_key_search(&mut self, key: KeyEvent) -> Option<Command> {
870 match key.code {
871 KeyCode::Esc => {
872 self.input_mode = InputMode::Normal;
873 self.search_query.clear();
874 self.active_filter = None;
875 None
876 }
877 KeyCode::Enter => {
878 self.input_mode = InputMode::Normal;
879 self.active_filter = if self.search_query.is_empty() {
880 None
881 } else {
882 Some(self.search_query.clone())
883 };
884 self.ui_state.selected_index = 0;
885 self.adjust_scroll();
886 None
887 }
888 KeyCode::Backspace => {
889 self.search_query.pop();
890 None
891 }
892 KeyCode::Char(c) => {
893 self.search_query.push(c);
894 None
895 }
896 _ => None,
897 }
898 }
899
900 fn jump_to_match(&mut self, forward: bool) {
902 if self.active_filter.is_none() {
903 return;
904 }
905 let items = self.visible_items();
906 if items.is_empty() {
907 return;
908 }
909
910 let pattern = self.active_filter.as_ref().unwrap().to_lowercase();
911 let len = items.len();
912 let start = self.ui_state.selected_index;
913
914 for offset in 1..=len {
916 let idx = if forward {
917 (start + offset) % len
918 } else {
919 (start + len - offset) % len
920 };
921 if let VisibleItem::FileHeader { file_idx } = &items[idx] {
922 let path = &self.diff_data.files[*file_idx].target_file;
923 if path.to_lowercase().contains(&pattern) {
924 self.ui_state.selected_index = idx;
925 self.adjust_scroll();
926 return;
927 }
928 }
929 }
930 }
931
932 fn move_selection(&mut self, delta: isize, items_len: usize) {
934 let max_idx = items_len.saturating_sub(1);
935 let new_idx = if delta > 0 {
936 (self.ui_state.selected_index + delta as usize).min(max_idx)
937 } else {
938 self.ui_state.selected_index.saturating_sub((-delta) as usize)
939 };
940 self.ui_state.selected_index = new_idx;
941 self.adjust_scroll();
942 }
943
944 fn toggle_collapse(&mut self) {
946 let items = self.visible_items();
947 if let Some(item) = items.get(self.ui_state.selected_index) {
948 let node_id = match item {
949 VisibleItem::FileHeader { file_idx } => Some(NodeId::File(*file_idx)),
950 VisibleItem::HunkHeader { file_idx, hunk_idx } => {
951 Some(NodeId::Hunk(*file_idx, *hunk_idx))
952 }
953 VisibleItem::DiffLine { .. } => None, };
955
956 if let Some(id) = node_id {
957 if self.ui_state.collapsed.contains(&id) {
958 self.ui_state.collapsed.remove(&id);
959 } else {
960 self.ui_state.collapsed.insert(id);
961 }
962
963 let new_items_len = self.visible_items().len();
965 if self.ui_state.selected_index >= new_items_len {
966 self.ui_state.selected_index = new_items_len.saturating_sub(1);
967 }
968 self.adjust_scroll();
969 }
970 }
971 }
972
973 fn item_char_width(&self, item: &VisibleItem) -> usize {
975 match item {
976 VisibleItem::FileHeader { file_idx } => {
977 let file = &self.diff_data.files[*file_idx];
978 let name = if file.is_rename {
979 format!(
980 "renamed: {} -> {}",
981 file.source_file.trim_start_matches("a/"),
982 file.target_file.trim_start_matches("b/")
983 )
984 } else {
985 file.target_file.trim_start_matches("b/").to_string()
986 };
987 3 + name.len()
989 + 1
990 + format!("+{}", file.added_count).len()
991 + format!(" -{}", file.removed_count).len()
992 }
993 VisibleItem::HunkHeader { file_idx, hunk_idx } => {
994 let hunk = &self.diff_data.files[*file_idx].hunks[*hunk_idx];
995 5 + hunk.header.len()
997 }
998 VisibleItem::DiffLine {
999 file_idx,
1000 hunk_idx,
1001 line_idx,
1002 } => {
1003 let line =
1004 &self.diff_data.files[*file_idx].hunks[*hunk_idx].lines[*line_idx];
1005 12 + line.content.len()
1007 }
1008 }
1009 }
1010
1011 pub fn item_visual_rows(&self, item: &VisibleItem, width: u16) -> usize {
1013 if width == 0 {
1014 return 1;
1015 }
1016 let char_width = self.item_char_width(item);
1017 char_width.div_ceil(width as usize).max(1)
1018 }
1019
1020 fn adjust_scroll(&mut self) {
1023 let width = self.ui_state.diff_view_width.get();
1024 let viewport = self.ui_state.viewport_height as usize;
1025 let items = self.visible_items();
1026 let selected = self.ui_state.selected_index;
1027
1028 if items.is_empty() || viewport == 0 {
1029 self.ui_state.scroll_offset = 0;
1030 return;
1031 }
1032
1033 let scroll = self.ui_state.scroll_offset as usize;
1034
1035 if selected < scroll {
1037 self.ui_state.scroll_offset = selected as u16;
1038 return;
1039 }
1040
1041 let mut rows = 0usize;
1043 for (i, item) in items.iter().enumerate().take(selected + 1).skip(scroll) {
1044 rows += self.item_visual_rows(item, width);
1045 if rows > viewport && i < selected {
1046 break;
1047 }
1048 }
1049
1050 if rows <= viewport {
1051 return;
1052 }
1053
1054 let selected_height = self.item_visual_rows(&items[selected], width);
1056 if selected_height >= viewport {
1057 self.ui_state.scroll_offset = selected as u16;
1058 return;
1059 }
1060
1061 let mut remaining = viewport - selected_height;
1062 let mut new_scroll = selected;
1063 for i in (0..selected).rev() {
1064 let h = self.item_visual_rows(&items[i], width);
1065 if h > remaining {
1066 break;
1067 }
1068 remaining -= h;
1069 new_scroll = i;
1070 }
1071 self.ui_state.scroll_offset = new_scroll as u16;
1072 }
1073
1074 pub fn visible_items(&self) -> Vec<VisibleItem> {
1077 let filter_lower = self
1078 .active_filter
1079 .as_ref()
1080 .map(|f| f.to_lowercase());
1081
1082 let mut items = Vec::new();
1083 for (fi, file) in self.diff_data.files.iter().enumerate() {
1084 let file_path = file.target_file.trim_start_matches("b/");
1085
1086 if let Some(ref pattern) = filter_lower {
1088 if !file.target_file.to_lowercase().contains(pattern) {
1089 continue;
1090 }
1091 }
1092
1093 let allowed_hunks: Option<&HashSet<usize>> =
1095 self.tree_filter.as_ref().and_then(|f| f.get(file_path));
1096
1097 if self.tree_filter.is_some() && allowed_hunks.is_none() {
1099 continue;
1100 }
1101
1102 items.push(VisibleItem::FileHeader { file_idx: fi });
1103 if !self.ui_state.collapsed.contains(&NodeId::File(fi)) {
1104 for (hi, hunk) in file.hunks.iter().enumerate() {
1105 if let Some(hunk_set) = allowed_hunks {
1108 if !hunk_set.is_empty() && !hunk_set.contains(&hi) {
1109 continue;
1110 }
1111 }
1112
1113 items.push(VisibleItem::HunkHeader {
1114 file_idx: fi,
1115 hunk_idx: hi,
1116 });
1117 if !self.ui_state.collapsed.contains(&NodeId::Hunk(fi, hi)) {
1118 for (li, _line) in hunk.lines.iter().enumerate() {
1119 items.push(VisibleItem::DiffLine {
1120 file_idx: fi,
1121 hunk_idx: hi,
1122 line_idx: li,
1123 });
1124 }
1125 }
1126 }
1127 }
1128 }
1129 items
1130 }
1131
1132 fn handle_key_preview(&mut self, key: KeyEvent) -> Option<Command> {
1134 match key.code {
1135 KeyCode::Char('j') | KeyCode::Down => {
1136 self.ui_state.preview_scroll = self.ui_state.preview_scroll.saturating_add(1);
1137 None
1138 }
1139 KeyCode::Char('k') | KeyCode::Up => {
1140 self.ui_state.preview_scroll = self.ui_state.preview_scroll.saturating_sub(1);
1141 None
1142 }
1143 KeyCode::Char('g') => {
1144 self.ui_state.preview_scroll = 0;
1145 None
1146 }
1147 KeyCode::Char('G') => {
1148 self.ui_state.preview_scroll = usize::MAX; None
1150 }
1151 KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1152 let half_page = (self.ui_state.viewport_height / 2) as usize;
1153 self.ui_state.preview_scroll = self.ui_state.preview_scroll.saturating_add(half_page);
1154 None
1155 }
1156 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1157 let half_page = (self.ui_state.viewport_height / 2) as usize;
1158 self.ui_state.preview_scroll = self.ui_state.preview_scroll.saturating_sub(half_page);
1159 None
1160 }
1161 KeyCode::Enter => None,
1163 KeyCode::Char('n') | KeyCode::Char('N') => None,
1165 _ => None,
1166 }
1167 }
1168
1169 pub fn view(&self, frame: &mut ratatui::Frame) -> Vec<crate::ui::preview_view::PendingImage> {
1172 crate::ui::draw(self, frame)
1173 }
1174}