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::review::{GroupReview, ReviewCache, ReviewSection, ReviewSource, SectionState};
7use crate::theme::Theme;
8use crate::ui::file_tree::TreeNodeId;
9use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
10use std::cell::{Cell, RefCell};
11use std::collections::{HashMap, HashSet};
12use tokio::sync::mpsc;
13use tui_tree_widget::TreeState;
14
15pub type HunkFilter = HashMap<String, HashSet<usize>>;
18
19#[derive(Debug, Clone, PartialEq)]
21pub enum InputMode {
22 Normal,
23 Search,
24 Help,
25 Settings,
26}
27
28#[derive(Debug, Clone, PartialEq)]
30pub enum FocusedPanel {
31 FileTree,
32 DiffView,
33}
34
35#[derive(Debug)]
37pub enum Message {
38 KeyPress(KeyEvent),
39 Resize(u16, u16),
40 RefreshSignal,
41 DebouncedRefresh,
42 DiffParsed(DiffData, String), GroupingComplete(Vec<SemanticGroup>, u64), GroupingFailed(String),
45 IncrementalGroupingComplete(
46 Vec<SemanticGroup>,
47 crate::grouper::DiffDelta,
48 HashMap<String, u64>,
49 u64, String, ),
52 MermaidReady,
54 ReviewSectionReady(u64, ReviewSection, Result<String, String>),
55}
56
57
58#[allow(dead_code)]
60pub enum Command {
61 SpawnDiffParse { git_diff_args: Vec<String> },
62 SpawnGrouping {
63 backend: LlmBackend,
64 model: String,
65 summaries: String,
66 diff_hash: u64,
67 head_commit: Option<String>,
68 file_hashes: HashMap<String, u64>,
69 },
70 SpawnIncrementalGrouping {
71 backend: LlmBackend,
72 model: String,
73 summaries: String,
74 diff_hash: u64,
75 head_commit: String,
76 file_hashes: HashMap<String, u64>,
77 delta: crate::grouper::DiffDelta,
78 },
79 SpawnReviewSection {
80 backend: crate::grouper::llm::LlmBackend,
81 model: String,
82 section: ReviewSection,
83 prompt: String,
84 group_content_hash: u64,
85 },
86 SpawnReviewBatch(Vec<Command>),
87 CancelReview(u64),
88 Quit,
89}
90
91#[derive(Debug, Clone, Hash, Eq, PartialEq)]
93pub enum NodeId {
94 File(usize),
95 Hunk(usize, usize),
96}
97
98pub struct UiState {
100 pub selected_index: usize,
101 pub scroll_offset: u16,
102 pub collapsed: HashSet<NodeId>,
103 pub viewport_height: u16,
105 pub diff_view_width: Cell<u16>,
107 pub preview_scroll: usize,
109}
110
111#[derive(Debug, Clone)]
113pub enum VisibleItem {
114 FileHeader { file_idx: usize },
115 HunkHeader { file_idx: usize, hunk_idx: usize },
116 DiffLine { file_idx: usize, hunk_idx: usize, line_idx: usize },
117}
118
119pub struct App {
121 pub diff_data: DiffData,
122 pub ui_state: UiState,
123 pub highlight_cache: HighlightCache,
124 #[allow(dead_code)]
125 pub should_quit: bool,
126 pub event_tx: Option<mpsc::Sender<Message>>,
128 pub debounce_handle: Option<tokio::task::JoinHandle<()>>,
130 pub input_mode: InputMode,
132 pub search_query: String,
134 pub active_filter: Option<String>,
136 pub semantic_groups: Option<Vec<SemanticGroup>>,
138 pub grouping_status: GroupingStatus,
140 pub grouping_handle: Option<tokio::task::JoinHandle<()>>,
142 pub llm_backend: Option<LlmBackend>,
144 pub llm_model: String,
146 pub focused_panel: FocusedPanel,
148 pub tree_state: RefCell<TreeState<TreeNodeId>>,
150 pub tree_filter: Option<HunkFilter>,
153 pub theme: Theme,
155 pub previous_head: Option<String>,
157 pub previous_file_hashes: HashMap<String, u64>,
159 pub git_diff_args: Vec<String>,
161 pub preview_mode: bool,
163 pub image_support: ImageSupport,
165 pub mermaid_cache: Option<MermaidCache>,
167 pub review_cache: ReviewCache,
168 pub review_handles: std::collections::HashMap<(u64, ReviewSection), tokio::task::JoinHandle<()>>,
169 pub active_review_group: Option<u64>,
170 pub review_scroll: usize,
171 pub review_source: ReviewSource,
172}
173
174impl App {
175 pub fn new(diff_data: DiffData, config: &crate::config::Config, git_diff_args: Vec<String>) -> Self {
177 let theme = Theme::from_mode(config.theme_mode);
178 let highlight_cache = HighlightCache::new(&diff_data, theme.syntect_theme);
179 let image_support = crate::preview::mermaid::detect_image_support();
180 let mermaid_cache = match &image_support {
181 ImageSupport::Supported(_) => Some(MermaidCache::new()),
182 _ => None,
183 };
184 Self {
185 diff_data,
186 ui_state: UiState {
187 selected_index: 0,
188 scroll_offset: 0,
189 collapsed: HashSet::new(),
190 viewport_height: 24, diff_view_width: Cell::new(80),
192 preview_scroll: 0,
193 },
194 highlight_cache,
195 should_quit: false,
196 event_tx: None,
197 debounce_handle: None,
198 input_mode: InputMode::Normal,
199 search_query: String::new(),
200 active_filter: None,
201 semantic_groups: None,
202 grouping_status: GroupingStatus::Idle,
203 grouping_handle: None,
204 llm_backend: config.detect_backend(),
205 llm_model: config
206 .detect_backend()
207 .map(|b| config.model_for_backend(b).to_string())
208 .unwrap_or_default(),
209 focused_panel: FocusedPanel::DiffView,
210 tree_state: RefCell::new(TreeState::default()),
211 tree_filter: None,
212 theme,
213 previous_head: None,
214 previous_file_hashes: HashMap::new(),
215 git_diff_args,
216 preview_mode: false,
217 image_support,
218 mermaid_cache,
219 review_cache: ReviewCache::new(),
220 review_handles: std::collections::HashMap::new(),
221 active_review_group: None,
222 review_scroll: 0,
223 review_source: crate::review::detect_review_skill(),
224 }
225 }
226
227 pub fn update(&mut self, msg: Message) -> Option<Command> {
229 match msg {
230 Message::KeyPress(key) => self.handle_key(key),
231 Message::Resize(_w, h) => {
232 self.ui_state.viewport_height = h.saturating_sub(1);
233 None
234 }
235 Message::RefreshSignal => {
236 if let Some(handle) = self.debounce_handle.take() {
238 handle.abort();
239 }
240 if let Some(tx) = &self.event_tx {
242 let tx = tx.clone();
243 self.debounce_handle = Some(tokio::spawn(async move {
244 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
245 let _ = tx.send(Message::DebouncedRefresh).await;
246 }));
247 }
248 None
249 }
250 Message::DebouncedRefresh => {
251 self.debounce_handle = None;
252 Some(Command::SpawnDiffParse {
253 git_diff_args: self.git_diff_args.clone(),
254 })
255 }
256 Message::DiffParsed(new_data, raw_diff) => {
257 self.apply_new_diff_data(new_data);
258 let hash = crate::cache::diff_hash(&raw_diff);
259 let current_head = crate::cache::get_head_commit();
260
261 if let Some(cached) = crate::cache::load(hash) {
263 let mut groups = cached;
264 crate::grouper::normalize_hunk_indices(&mut groups, &self.diff_data);
265 self.semantic_groups = Some(groups);
266 self.grouping_status = GroupingStatus::Done;
267 self.grouping_handle = None;
268 if let Some(ref head) = current_head {
270 self.previous_head = Some(head.clone());
271 }
272 self.previous_file_hashes =
273 crate::grouper::compute_all_file_hashes(&self.diff_data);
274 return None;
275 }
276
277 let can_incremental = current_head.is_some()
279 && self.previous_head.as_ref() == current_head.as_ref()
280 && self.semantic_groups.is_some()
281 && !self.previous_file_hashes.is_empty();
282
283 if can_incremental {
284 let new_hashes = crate::grouper::compute_all_file_hashes(&self.diff_data);
285 let delta =
286 crate::grouper::compute_diff_delta(&new_hashes, &self.previous_file_hashes);
287
288 if !delta.has_changes() {
289 self.grouping_status = GroupingStatus::Done;
291 return None;
292 }
293
294 if delta.is_only_removals() {
295 let mut groups = self.semantic_groups.clone().unwrap_or_default();
297 crate::grouper::remove_files_from_groups(&mut groups, &delta.removed_files);
298 crate::grouper::normalize_hunk_indices(&mut groups, &self.diff_data);
299 self.semantic_groups = Some(groups);
300 self.grouping_status = GroupingStatus::Done;
301 self.previous_file_hashes = new_hashes.clone();
302 if let Some(ref head) = current_head {
304 crate::cache::save_with_state(
305 hash,
306 self.semantic_groups.as_ref().unwrap(),
307 Some(head),
308 &new_hashes,
309 );
310 }
311 return None;
312 }
313
314 if let Some(backend) = self.llm_backend {
316 if let Some(handle) = self.grouping_handle.take() {
317 handle.abort();
318 }
319 self.grouping_status = GroupingStatus::Loading;
320 let existing = self.semantic_groups.as_ref().unwrap();
321 let summaries = crate::grouper::incremental_hunk_summaries(
322 &self.diff_data,
323 &delta,
324 existing,
325 );
326 tracing::info!(
327 new = delta.new_files.len(),
328 modified = delta.modified_files.len(),
329 removed = delta.removed_files.len(),
330 unchanged = delta.unchanged_files.len(),
331 "Incremental grouping"
332 );
333 return Some(Command::SpawnIncrementalGrouping {
334 backend,
335 model: self.llm_model.clone(),
336 summaries,
337 diff_hash: hash,
338 head_commit: current_head.unwrap(),
339 file_hashes: new_hashes,
340 delta,
341 });
342 }
343 }
344
345 if let Some(backend) = self.llm_backend {
347 if let Some(handle) = self.grouping_handle.take() {
349 handle.abort();
350 }
351 self.grouping_status = GroupingStatus::Loading;
352 let summaries = crate::grouper::hunk_summaries(&self.diff_data);
353 let file_hashes = crate::grouper::compute_all_file_hashes(&self.diff_data);
354 Some(Command::SpawnGrouping {
355 backend,
356 model: self.llm_model.clone(),
357 summaries,
358 diff_hash: hash,
359 head_commit: current_head,
360 file_hashes,
361 })
362 } else {
363 self.grouping_status = GroupingStatus::Idle;
364 None
365 }
366 }
367 Message::GroupingComplete(groups, diff_hash) => {
368 let mut groups = groups;
369 crate::grouper::normalize_hunk_indices(&mut groups, &self.diff_data);
370 let current_head = crate::cache::get_head_commit();
372 let file_hashes = crate::grouper::compute_all_file_hashes(&self.diff_data);
373 crate::cache::save_with_state(
375 diff_hash,
376 &groups,
377 current_head.as_deref(),
378 &file_hashes,
379 );
380 self.previous_head = current_head;
381 self.previous_file_hashes = file_hashes;
382 self.semantic_groups = Some(groups);
383 self.grouping_status = GroupingStatus::Done;
384 self.grouping_handle = None;
385 let mut ts = self.tree_state.borrow_mut();
387 *ts = TreeState::default();
388 ts.select_first();
389 drop(ts);
390 self.tree_filter = None;
392 self.spawn_all_reviews()
394 }
395 Message::IncrementalGroupingComplete(new_assignments, delta, file_hashes, diff_hash, head_commit) => {
396 let existing = self.semantic_groups.as_ref().cloned().unwrap_or_default();
397 let mut merged =
398 crate::grouper::merge_groups(&existing, &new_assignments, &delta);
399 crate::grouper::normalize_hunk_indices(&mut merged, &self.diff_data);
400 crate::cache::save_with_state(
402 diff_hash,
403 &merged,
404 Some(&head_commit),
405 &file_hashes,
406 );
407 self.semantic_groups = Some(merged);
408 self.grouping_status = GroupingStatus::Done;
409 self.grouping_handle = None;
410 self.previous_file_hashes = file_hashes;
411 self.previous_head = Some(head_commit);
412 let mut ts = self.tree_state.borrow_mut();
414 *ts = TreeState::default();
415 ts.select_first();
416 drop(ts);
417 self.tree_filter = None;
418 self.spawn_all_reviews()
420 }
421 Message::GroupingFailed(err) => {
422 tracing::warn!("Grouping failed: {}", err);
423 self.grouping_status = GroupingStatus::Error(err);
424 self.grouping_handle = None;
425 None }
427 Message::MermaidReady => None, Message::ReviewSectionReady(hash, section, result) => {
429 self.review_handles.remove(&(hash, section));
430 if let Some(review) = self.review_cache.get_mut(&hash) {
431 match result {
432 Ok(content) => {
433 if section == ReviewSection::How && content.trim() == "SKIP" {
434 review.sections.insert(section, SectionState::Skipped);
435 } else {
436 review.sections.insert(section, SectionState::Ready(content));
437 }
438 }
439 Err(msg) => {
440 review.sections.insert(section, SectionState::Error(msg));
441 }
442 }
443 let all_complete = review.sections.values().all(|s| s.is_complete());
444 if all_complete {
445 let review_clone = review.clone();
446 crate::review::save_review_to_disk(&review_clone);
447 }
448 }
449 None
450 }
451 }
452 }
453
454 fn apply_new_diff_data(&mut self, new_data: DiffData) {
456 let mut collapsed_files: HashSet<String> = HashSet::new();
458 let mut collapsed_hunks: HashSet<(String, usize)> = HashSet::new();
459
460 for node in &self.ui_state.collapsed {
461 match node {
462 NodeId::File(fi) => {
463 if let Some(file) = self.diff_data.files.get(*fi) {
464 collapsed_files.insert(file.target_file.clone());
465 }
466 }
467 NodeId::Hunk(fi, hi) => {
468 if let Some(file) = self.diff_data.files.get(*fi) {
469 collapsed_hunks.insert((file.target_file.clone(), *hi));
470 }
471 }
472 }
473 }
474
475 let selected_path = self.selected_file_path();
477
478 self.diff_data = new_data;
480 self.highlight_cache = HighlightCache::new(&self.diff_data, self.theme.syntect_theme);
481
482 self.ui_state.collapsed.clear();
484 for (fi, file) in self.diff_data.files.iter().enumerate() {
485 if collapsed_files.contains(&file.target_file) {
486 self.ui_state.collapsed.insert(NodeId::File(fi));
487 }
488 for (hi, _) in file.hunks.iter().enumerate() {
489 if collapsed_hunks.contains(&(file.target_file.clone(), hi)) {
490 self.ui_state.collapsed.insert(NodeId::Hunk(fi, hi));
491 }
492 }
493 }
494
495 if let Some(path) = selected_path {
497 let items = self.visible_items();
498 let restored = items.iter().position(|item| {
499 if let VisibleItem::FileHeader { file_idx } = item {
500 self.diff_data.files[*file_idx].target_file == path
501 } else {
502 false
503 }
504 });
505 if let Some(idx) = restored {
506 self.ui_state.selected_index = idx;
507 } else {
508 self.ui_state.selected_index = self
509 .ui_state
510 .selected_index
511 .min(items.len().saturating_sub(1));
512 }
513 } else {
514 let items_len = self.visible_items().len();
515 self.ui_state.selected_index = self
516 .ui_state
517 .selected_index
518 .min(items_len.saturating_sub(1));
519 }
520
521 self.adjust_scroll();
522 }
523
524 fn selected_file_path(&self) -> Option<String> {
526 let items = self.visible_items();
527 let item = items.get(self.ui_state.selected_index)?;
528 let fi = match item {
529 VisibleItem::FileHeader { file_idx } => *file_idx,
530 VisibleItem::HunkHeader { file_idx, .. } => *file_idx,
531 VisibleItem::DiffLine { file_idx, .. } => *file_idx,
532 };
533 self.diff_data.files.get(fi).map(|f| f.target_file.clone())
534 }
535
536 fn handle_key(&mut self, key: KeyEvent) -> Option<Command> {
538 match self.input_mode {
539 InputMode::Normal => self.handle_key_normal(key),
540 InputMode::Search => self.handle_key_search(key),
541 InputMode::Help => {
542 self.input_mode = InputMode::Normal;
544 None
545 }
546 InputMode::Settings => self.handle_key_settings(key),
547 }
548 }
549
550 fn handle_key_settings(&mut self, key: KeyEvent) -> Option<Command> {
552 match key.code {
553 KeyCode::Char('d') => {
554 self.toggle_theme();
555 None
556 }
557 KeyCode::Esc => {
558 self.input_mode = InputMode::Normal;
559 None
560 }
561 _ => None,
562 }
563 }
564
565 pub fn toggle_theme(&mut self) {
567 let new_theme = if self.theme.syntect_theme.contains("dark") {
568 crate::theme::Theme::light()
569 } else {
570 crate::theme::Theme::dark()
571 };
572 self.theme = new_theme;
573 self.highlight_cache = HighlightCache::new(&self.diff_data, self.theme.syntect_theme);
574 }
575
576 fn handle_key_normal(&mut self, key: KeyEvent) -> Option<Command> {
578 match key.code {
580 KeyCode::Char('q') => return Some(Command::Quit),
581 KeyCode::Char('?') => {
582 self.input_mode = InputMode::Help;
583 return None;
584 }
585 KeyCode::Char(',') => {
586 self.input_mode = InputMode::Settings;
587 return None;
588 }
589 KeyCode::Tab => {
590 self.focused_panel = match self.focused_panel {
591 FocusedPanel::FileTree => FocusedPanel::DiffView,
592 FocusedPanel::DiffView => FocusedPanel::FileTree,
593 };
594 return None;
595 }
596 KeyCode::Esc => {
597 if self.tree_filter.is_some() || self.active_filter.is_some() {
598 self.tree_filter = None;
599 self.active_filter = None;
600 self.ui_state.selected_index = 0;
601 self.adjust_scroll();
602 return None;
603 } else {
604 return Some(Command::Quit);
605 }
606 }
607 KeyCode::Char('/') => {
608 self.input_mode = InputMode::Search;
609 self.search_query.clear();
610 return None;
611 }
612 KeyCode::Char('p') => {
613 if crate::ui::preview_view::is_current_file_markdown(self) {
614 self.preview_mode = !self.preview_mode;
615 if self.preview_mode {
616 self.ui_state.preview_scroll = 0;
617 }
618 }
619 return None;
620 }
621 _ => {}
622 }
623
624 match self.focused_panel {
626 FocusedPanel::FileTree => self.handle_key_tree(key),
627 FocusedPanel::DiffView => self.handle_key_diff(key),
628 }
629 }
630
631 fn handle_key_tree(&mut self, key: KeyEvent) -> Option<Command> {
633 let mut ts = self.tree_state.borrow_mut();
634 match key.code {
635 KeyCode::Char('j') | KeyCode::Down => {
636 ts.key_down();
637 }
638 KeyCode::Char('k') | KeyCode::Up => {
639 ts.key_up();
640 }
641 KeyCode::Left => {
642 ts.key_left();
643 }
644 KeyCode::Right => {
645 ts.key_right();
646 }
647 KeyCode::Enter => {
648 ts.toggle_selected();
649 }
650 KeyCode::Char('g') => {
651 ts.select_first();
652 }
653 KeyCode::Char('G') => {
654 ts.select_last();
655 }
656 _ => return None,
657 }
658
659 let selected = ts.selected().to_vec();
661 drop(ts); self.apply_tree_selection(&selected)
663 }
664
665 fn apply_tree_selection(&mut self, selected: &[TreeNodeId]) -> Option<Command> {
667 match selected.last() {
668 Some(TreeNodeId::File(group_idx, path)) => {
669 self.select_tree_file(path, *group_idx);
670 self.active_review_group = None;
673 self.review_scroll = 0;
674 None
675 }
676 Some(TreeNodeId::Group(gi)) => {
677 self.select_tree_group(*gi);
678 if let Some(groups) = &self.semantic_groups {
683 if let Some(group) = groups.get(*gi) {
684 let hash = crate::review::group_content_hash(group);
685 if self.active_review_group == Some(hash) {
686 return None; }
688 self.active_review_group = Some(hash);
689 self.review_scroll = 0;
690
691 if self.review_cache.get(&hash).is_none() {
693 if let Some(cached) = crate::review::load_review_from_disk(hash, &self.review_source) {
694 self.review_cache.insert(cached);
695 }
696 }
697 if self.review_cache.get(&hash).is_none() {
699 return self.spawn_all_reviews();
700 }
701 }
702 }
703 None
704 }
705 None => None,
706 }
707 }
708
709 pub fn spawn_all_reviews(&mut self) -> Option<Command> {
713 let backend = self.llm_backend?;
714 let groups = self.semantic_groups.as_ref()?.clone();
715 let mut all_cmds: Vec<Command> = Vec::new();
716
717 for group in &groups {
718 let hash = crate::review::group_content_hash(group);
719
720 if self.review_cache.get(&hash).is_some() {
722 continue;
723 }
724
725 if let Some(cached) = crate::review::load_review_from_disk(hash, &self.review_source) {
727 self.review_cache.insert(cached);
728 continue;
729 }
730
731 let mut sections_map = std::collections::HashMap::new();
733 for s in ReviewSection::all() {
734 sections_map.insert(s, SectionState::Loading);
735 }
736 self.review_cache.insert(GroupReview {
737 content_hash: hash,
738 sections: sections_map,
739 source: self.review_source.clone(),
740 });
741
742 let model = self.llm_model.clone();
743 let review_source = self.review_source.clone();
744 for §ion in &ReviewSection::all() {
745 let prompt = crate::review::llm::build_review_prompt(
746 section, group, &self.diff_data, &review_source,
747 );
748 all_cmds.push(Command::SpawnReviewSection {
749 backend,
750 model: model.clone(),
751 section,
752 prompt,
753 group_content_hash: hash,
754 });
755 }
756 }
757
758 if all_cmds.is_empty() {
759 None
760 } else {
761 Some(Command::SpawnReviewBatch(all_cmds))
762 }
763 }
764
765 fn handle_key_diff(&mut self, key: KeyEvent) -> Option<Command> {
767 if self.active_review_group.is_some() {
769 return self.handle_key_review(key);
770 }
771
772 if self.preview_mode {
774 return self.handle_key_preview(key);
775 }
776
777 let items_len = self.visible_items().len();
778 if items_len == 0 {
779 return None;
780 }
781
782 match key.code {
783 KeyCode::Char('n') => {
785 self.jump_to_match(true);
786 None
787 }
788 KeyCode::Char('N') => {
789 self.jump_to_match(false);
790 None
791 }
792
793 KeyCode::Char('j') | KeyCode::Down => {
795 self.move_selection(1, items_len);
796 None
797 }
798 KeyCode::Char('k') | KeyCode::Up => {
799 self.move_selection(-1, items_len);
800 None
801 }
802 KeyCode::Char('g') => {
803 self.ui_state.selected_index = 0;
804 self.adjust_scroll();
805 None
806 }
807 KeyCode::Char('G') => {
808 self.ui_state.selected_index = items_len.saturating_sub(1);
809 self.adjust_scroll();
810 None
811 }
812 KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
813 let half_page = (self.ui_state.viewport_height / 2) as usize;
814 self.move_selection(half_page as isize, items_len);
815 None
816 }
817 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
818 let half_page = (self.ui_state.viewport_height / 2) as usize;
819 self.move_selection(-(half_page as isize), items_len);
820 None
821 }
822
823 KeyCode::Enter => {
825 self.toggle_collapse();
826 None
827 }
828
829 _ => None,
830 }
831 }
832
833 fn select_tree_file(&mut self, path: &str, group_idx: Option<usize>) {
836 let filter = self.hunk_filter_for_file(path, group_idx);
837 self.tree_filter = Some(filter);
839 let items = self.visible_items();
841 let target_idx = items.iter().position(|item| {
842 if let VisibleItem::FileHeader { file_idx } = item {
843 self.diff_data.files[*file_idx]
844 .target_file
845 .trim_start_matches("b/")
846 == path
847 } else {
848 false
849 }
850 });
851 self.ui_state.selected_index = target_idx.unwrap_or(0);
852 self.ui_state.scroll_offset = self.ui_state.selected_index as u16;
854 }
855
856 fn select_tree_group(&mut self, group_idx: usize) {
858 let filter = self.hunk_filter_for_group(group_idx);
859 if filter.is_empty() {
860 return;
861 }
862 self.tree_filter = Some(filter);
863 self.ui_state.selected_index = 0;
864 self.ui_state.scroll_offset = 0;
865 }
866
867 fn hunk_filter_for_file(&self, path: &str, group_idx: Option<usize>) -> HunkFilter {
870 if let Some(groups) = &self.semantic_groups {
871 if let Some(gi) = group_idx {
872 if gi >= groups.len() {
873 return self.hunk_filter_for_file_in_other(path);
875 }
876 if let Some(group) = groups.get(gi) {
877 if let Some(filter) = self.hunk_filter_for_file_in_group(path, group) {
878 return filter;
879 }
880 }
881 }
882 for group in groups.iter() {
885 if let Some(filter) = self.hunk_filter_for_file_in_group(path, group) {
886 return filter;
887 }
888 }
889 return self.hunk_filter_for_file_in_other(path);
890 }
891 let mut filter = HunkFilter::new();
893 filter.insert(path.to_string(), HashSet::new());
894 filter
895 }
896
897 fn hunk_filter_for_file_in_group(
899 &self,
900 path: &str,
901 group: &crate::grouper::SemanticGroup,
902 ) -> Option<HunkFilter> {
903 for change in &group.changes() {
904 if let Some(diff_path) = self.resolve_diff_path(&change.file) {
905 if diff_path == path {
906 let mut filter = HunkFilter::new();
907 let hunk_set: HashSet<usize> = change.hunks.iter().copied().collect();
908 filter.insert(diff_path, hunk_set);
909 return Some(filter);
910 }
911 }
912 }
913 None
914 }
915
916 fn hunk_filter_for_file_in_other(&self, path: &str) -> HunkFilter {
918 let other = self.hunk_filter_for_other();
919 let mut filter = HunkFilter::new();
920 if let Some(hunk_set) = other.get(path) {
921 filter.insert(path.to_string(), hunk_set.clone());
922 } else {
923 filter.insert(path.to_string(), HashSet::new());
924 }
925 filter
926 }
927
928 fn hunk_filter_for_group(&self, group_idx: usize) -> HunkFilter {
930 if let Some(groups) = &self.semantic_groups {
931 if let Some(group) = groups.get(group_idx) {
932 let mut filter = HunkFilter::new();
933 for change in &group.changes() {
934 if let Some(diff_path) = self.resolve_diff_path(&change.file) {
936 let hunk_set: HashSet<usize> = change.hunks.iter().copied().collect();
937 filter
938 .entry(diff_path)
939 .or_default()
940 .extend(hunk_set.iter());
941 }
942 }
943 return filter;
944 }
945 if group_idx >= groups.len() {
947 return self.hunk_filter_for_other();
948 }
949 }
950 HunkFilter::new()
951 }
952
953 fn hunk_filter_for_other(&self) -> HunkFilter {
955 let groups = match &self.semantic_groups {
956 Some(g) => g,
957 None => return HunkFilter::new(),
958 };
959
960 let mut grouped: HashMap<String, HashSet<usize>> = HashMap::new();
962 for group in groups {
963 for change in &group.changes() {
964 if let Some(dp) = self.resolve_diff_path(&change.file) {
965 grouped.entry(dp).or_default().extend(change.hunks.iter());
966 }
967 }
968 }
969
970 let mut filter = HunkFilter::new();
972 for file in &self.diff_data.files {
973 let dp = file.target_file.trim_start_matches("b/").to_string();
974 if let Some(grouped_hunks) = grouped.get(&dp) {
975 if grouped_hunks.is_empty() {
977 continue;
978 }
979 let ungrouped: HashSet<usize> = (0..file.hunks.len())
980 .filter(|hi| !grouped_hunks.contains(hi))
981 .collect();
982 if !ungrouped.is_empty() {
983 filter.insert(dp, ungrouped);
984 }
985 } else {
986 filter.insert(dp, HashSet::new());
988 }
989 }
990 filter
991 }
992
993 fn resolve_diff_path(&self, group_path: &str) -> Option<String> {
995 self.diff_data.files.iter().find_map(|f| {
996 let dp = f.target_file.trim_start_matches("b/");
997 if dp == group_path || dp.ends_with(group_path) {
998 Some(dp.to_string())
999 } else {
1000 None
1001 }
1002 })
1003 }
1004
1005 fn handle_key_search(&mut self, key: KeyEvent) -> Option<Command> {
1007 match key.code {
1008 KeyCode::Esc => {
1009 self.input_mode = InputMode::Normal;
1010 self.search_query.clear();
1011 self.active_filter = None;
1012 None
1013 }
1014 KeyCode::Enter => {
1015 self.input_mode = InputMode::Normal;
1016 self.active_filter = if self.search_query.is_empty() {
1017 None
1018 } else {
1019 Some(self.search_query.clone())
1020 };
1021 self.ui_state.selected_index = 0;
1022 self.adjust_scroll();
1023 None
1024 }
1025 KeyCode::Backspace => {
1026 self.search_query.pop();
1027 None
1028 }
1029 KeyCode::Char(c) => {
1030 self.search_query.push(c);
1031 None
1032 }
1033 _ => None,
1034 }
1035 }
1036
1037 fn jump_to_match(&mut self, forward: bool) {
1039 if self.active_filter.is_none() {
1040 return;
1041 }
1042 let items = self.visible_items();
1043 if items.is_empty() {
1044 return;
1045 }
1046
1047 let pattern = self.active_filter.as_ref().unwrap().to_lowercase();
1048 let len = items.len();
1049 let start = self.ui_state.selected_index;
1050
1051 for offset in 1..=len {
1053 let idx = if forward {
1054 (start + offset) % len
1055 } else {
1056 (start + len - offset) % len
1057 };
1058 if let VisibleItem::FileHeader { file_idx } = &items[idx] {
1059 let path = &self.diff_data.files[*file_idx].target_file;
1060 if path.to_lowercase().contains(&pattern) {
1061 self.ui_state.selected_index = idx;
1062 self.adjust_scroll();
1063 return;
1064 }
1065 }
1066 }
1067 }
1068
1069 fn move_selection(&mut self, delta: isize, items_len: usize) {
1071 let max_idx = items_len.saturating_sub(1);
1072 let new_idx = if delta > 0 {
1073 (self.ui_state.selected_index + delta as usize).min(max_idx)
1074 } else {
1075 self.ui_state.selected_index.saturating_sub((-delta) as usize)
1076 };
1077 self.ui_state.selected_index = new_idx;
1078 self.adjust_scroll();
1079 }
1080
1081 fn toggle_collapse(&mut self) {
1083 let items = self.visible_items();
1084 if let Some(item) = items.get(self.ui_state.selected_index) {
1085 let node_id = match item {
1086 VisibleItem::FileHeader { file_idx } => Some(NodeId::File(*file_idx)),
1087 VisibleItem::HunkHeader { file_idx, hunk_idx } => {
1088 Some(NodeId::Hunk(*file_idx, *hunk_idx))
1089 }
1090 VisibleItem::DiffLine { .. } => None, };
1092
1093 if let Some(id) = node_id {
1094 if self.ui_state.collapsed.contains(&id) {
1095 self.ui_state.collapsed.remove(&id);
1096 } else {
1097 self.ui_state.collapsed.insert(id);
1098 }
1099
1100 let new_items_len = self.visible_items().len();
1102 if self.ui_state.selected_index >= new_items_len {
1103 self.ui_state.selected_index = new_items_len.saturating_sub(1);
1104 }
1105 self.adjust_scroll();
1106 }
1107 }
1108 }
1109
1110 fn item_char_width(&self, item: &VisibleItem) -> usize {
1112 match item {
1113 VisibleItem::FileHeader { file_idx } => {
1114 let file = &self.diff_data.files[*file_idx];
1115 let name = if file.is_rename {
1116 format!(
1117 "renamed: {} -> {}",
1118 file.source_file.trim_start_matches("a/"),
1119 file.target_file.trim_start_matches("b/")
1120 )
1121 } else {
1122 file.target_file.trim_start_matches("b/").to_string()
1123 };
1124 3 + name.len()
1126 + 1
1127 + format!("+{}", file.added_count).len()
1128 + format!(" -{}", file.removed_count).len()
1129 }
1130 VisibleItem::HunkHeader { file_idx, hunk_idx } => {
1131 let hunk = &self.diff_data.files[*file_idx].hunks[*hunk_idx];
1132 5 + hunk.header.len()
1134 }
1135 VisibleItem::DiffLine {
1136 file_idx,
1137 hunk_idx,
1138 line_idx,
1139 } => {
1140 let line =
1141 &self.diff_data.files[*file_idx].hunks[*hunk_idx].lines[*line_idx];
1142 12 + line.content.len()
1144 }
1145 }
1146 }
1147
1148 pub fn item_visual_rows(&self, item: &VisibleItem, width: u16) -> usize {
1150 if width == 0 {
1151 return 1;
1152 }
1153 let char_width = self.item_char_width(item);
1154 char_width.div_ceil(width as usize).max(1)
1155 }
1156
1157 fn adjust_scroll(&mut self) {
1160 let width = self.ui_state.diff_view_width.get();
1161 let viewport = self.ui_state.viewport_height as usize;
1162 let items = self.visible_items();
1163 let selected = self.ui_state.selected_index;
1164
1165 if items.is_empty() || viewport == 0 {
1166 self.ui_state.scroll_offset = 0;
1167 return;
1168 }
1169
1170 let scroll = self.ui_state.scroll_offset as usize;
1171
1172 if selected < scroll {
1174 self.ui_state.scroll_offset = selected as u16;
1175 return;
1176 }
1177
1178 let mut rows = 0usize;
1180 for (i, item) in items.iter().enumerate().take(selected + 1).skip(scroll) {
1181 rows += self.item_visual_rows(item, width);
1182 if rows > viewport && i < selected {
1183 break;
1184 }
1185 }
1186
1187 if rows <= viewport {
1188 return;
1189 }
1190
1191 let selected_height = self.item_visual_rows(&items[selected], width);
1193 if selected_height >= viewport {
1194 self.ui_state.scroll_offset = selected as u16;
1195 return;
1196 }
1197
1198 let mut remaining = viewport - selected_height;
1199 let mut new_scroll = selected;
1200 for i in (0..selected).rev() {
1201 let h = self.item_visual_rows(&items[i], width);
1202 if h > remaining {
1203 break;
1204 }
1205 remaining -= h;
1206 new_scroll = i;
1207 }
1208 self.ui_state.scroll_offset = new_scroll as u16;
1209 }
1210
1211 pub fn visible_items(&self) -> Vec<VisibleItem> {
1214 let filter_lower = self
1215 .active_filter
1216 .as_ref()
1217 .map(|f| f.to_lowercase());
1218
1219 let mut items = Vec::new();
1220 for (fi, file) in self.diff_data.files.iter().enumerate() {
1221 let file_path = file.target_file.trim_start_matches("b/");
1222
1223 if let Some(ref pattern) = filter_lower {
1225 if !file.target_file.to_lowercase().contains(pattern) {
1226 continue;
1227 }
1228 }
1229
1230 let allowed_hunks: Option<&HashSet<usize>> =
1232 self.tree_filter.as_ref().and_then(|f| f.get(file_path));
1233
1234 if self.tree_filter.is_some() && allowed_hunks.is_none() {
1236 continue;
1237 }
1238
1239 items.push(VisibleItem::FileHeader { file_idx: fi });
1240 if !self.ui_state.collapsed.contains(&NodeId::File(fi)) {
1241 for (hi, hunk) in file.hunks.iter().enumerate() {
1242 if let Some(hunk_set) = allowed_hunks {
1245 if !hunk_set.is_empty() && !hunk_set.contains(&hi) {
1246 continue;
1247 }
1248 }
1249
1250 items.push(VisibleItem::HunkHeader {
1251 file_idx: fi,
1252 hunk_idx: hi,
1253 });
1254 if !self.ui_state.collapsed.contains(&NodeId::Hunk(fi, hi)) {
1255 for (li, _line) in hunk.lines.iter().enumerate() {
1256 items.push(VisibleItem::DiffLine {
1257 file_idx: fi,
1258 hunk_idx: hi,
1259 line_idx: li,
1260 });
1261 }
1262 }
1263 }
1264 }
1265 }
1266 items
1267 }
1268
1269 fn handle_key_review(&mut self, key: KeyEvent) -> Option<Command> {
1271 match key.code {
1272 KeyCode::Char('j') | KeyCode::Down => {
1273 self.review_scroll = self.review_scroll.saturating_add(1);
1274 None
1275 }
1276 KeyCode::Char('k') | KeyCode::Up => {
1277 self.review_scroll = self.review_scroll.saturating_sub(1);
1278 None
1279 }
1280 KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1281 self.review_scroll = self.review_scroll.saturating_add(
1282 (self.ui_state.viewport_height / 2) as usize,
1283 );
1284 None
1285 }
1286 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1287 self.review_scroll = self.review_scroll.saturating_sub(
1288 (self.ui_state.viewport_height / 2) as usize,
1289 );
1290 None
1291 }
1292 KeyCode::Char('g') => {
1293 self.review_scroll = 0;
1294 None
1295 }
1296 KeyCode::Char('G') => {
1297 self.review_scroll = 999; None
1299 }
1300 KeyCode::Char('R') => {
1301 if let Some(hash) = self.active_review_group {
1303 let keys: Vec<_> = self
1305 .review_handles
1306 .keys()
1307 .filter(|(h, _)| *h == hash)
1308 .cloned()
1309 .collect();
1310 for key in keys {
1311 if let Some(handle) = self.review_handles.remove(&key) {
1312 handle.abort();
1313 }
1314 }
1315 self.review_cache.remove(&hash);
1317 crate::review::delete_review_from_disk(hash);
1318 self.active_review_group = None;
1319
1320 let selected = self.tree_state.borrow().selected().to_vec();
1322 return self.apply_tree_selection(&selected);
1323 }
1324 None
1325 }
1326 KeyCode::Esc => {
1327 if let Some(old_hash) = self.active_review_group.take() {
1329 let keys: Vec<_> = self
1330 .review_handles
1331 .keys()
1332 .filter(|(h, _)| *h == old_hash)
1333 .cloned()
1334 .collect();
1335 for key in keys {
1336 if let Some(handle) = self.review_handles.remove(&key) {
1337 handle.abort();
1338 }
1339 }
1340 }
1341 self.review_scroll = 0;
1342 None
1343 }
1344 _ => None,
1345 }
1346 }
1347
1348 fn handle_key_preview(&mut self, key: KeyEvent) -> Option<Command> {
1350 match key.code {
1351 KeyCode::Char('j') | KeyCode::Down => {
1352 self.ui_state.preview_scroll = self.ui_state.preview_scroll.saturating_add(1);
1353 None
1354 }
1355 KeyCode::Char('k') | KeyCode::Up => {
1356 self.ui_state.preview_scroll = self.ui_state.preview_scroll.saturating_sub(1);
1357 None
1358 }
1359 KeyCode::Char('g') => {
1360 self.ui_state.preview_scroll = 0;
1361 None
1362 }
1363 KeyCode::Char('G') => {
1364 self.ui_state.preview_scroll = usize::MAX; None
1366 }
1367 KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1368 let half_page = (self.ui_state.viewport_height / 2) as usize;
1369 self.ui_state.preview_scroll = self.ui_state.preview_scroll.saturating_add(half_page);
1370 None
1371 }
1372 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1373 let half_page = (self.ui_state.viewport_height / 2) as usize;
1374 self.ui_state.preview_scroll = self.ui_state.preview_scroll.saturating_sub(half_page);
1375 None
1376 }
1377 KeyCode::Enter => None,
1379 KeyCode::Char('n') | KeyCode::Char('N') => None,
1381 _ => None,
1382 }
1383 }
1384
1385 pub fn view(&self, frame: &mut ratatui::Frame) -> Vec<crate::ui::preview_view::PendingImage> {
1388 crate::ui::draw(self, frame)
1389 }
1390}