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