1use crate::diff::DiffData;
2use crate::grouper::llm::LlmBackend;
3use crate::grouper::{GroupingStatus, SemanticGroup};
4use crate::highlight::HighlightCache;
5use crate::ui::file_tree::TreeNodeId;
6use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
7use std::cell::{Cell, RefCell};
8use std::collections::{HashMap, HashSet};
9use tokio::sync::mpsc;
10use tui_tree_widget::TreeState;
11
12pub type HunkFilter = HashMap<String, HashSet<usize>>;
15
16#[derive(Debug, Clone, PartialEq)]
18pub enum InputMode {
19 Normal,
20 Search,
21 Help,
22}
23
24#[derive(Debug, Clone, PartialEq)]
26pub enum FocusedPanel {
27 FileTree,
28 DiffView,
29}
30
31#[derive(Debug)]
33pub enum Message {
34 KeyPress(KeyEvent),
35 Resize(u16, u16),
36 RefreshSignal,
37 DebouncedRefresh,
38 DiffParsed(DiffData, String), GroupingComplete(Vec<SemanticGroup>),
40 GroupingFailed(String),
41}
42
43
44pub enum Command {
46 SpawnDiffParse,
47 SpawnGrouping {
48 backend: LlmBackend,
49 model: String,
50 summaries: String,
51 diff_hash: u64,
52 },
53 Quit,
54}
55
56#[derive(Debug, Clone, Hash, Eq, PartialEq)]
58pub enum NodeId {
59 File(usize),
60 Hunk(usize, usize),
61}
62
63pub struct UiState {
65 pub selected_index: usize,
66 pub scroll_offset: u16,
67 pub collapsed: HashSet<NodeId>,
68 pub viewport_height: u16,
70 pub diff_view_width: Cell<u16>,
72}
73
74#[derive(Debug, Clone)]
76pub enum VisibleItem {
77 FileHeader { file_idx: usize },
78 HunkHeader { file_idx: usize, hunk_idx: usize },
79 DiffLine { file_idx: usize, hunk_idx: usize, line_idx: usize },
80}
81
82pub struct App {
84 pub diff_data: DiffData,
85 pub ui_state: UiState,
86 pub highlight_cache: HighlightCache,
87 #[allow(dead_code)]
88 pub should_quit: bool,
89 pub event_tx: Option<mpsc::Sender<Message>>,
91 pub debounce_handle: Option<tokio::task::JoinHandle<()>>,
93 pub input_mode: InputMode,
95 pub search_query: String,
97 pub active_filter: Option<String>,
99 pub semantic_groups: Option<Vec<SemanticGroup>>,
101 pub grouping_status: GroupingStatus,
103 pub grouping_handle: Option<tokio::task::JoinHandle<()>>,
105 pub llm_backend: Option<LlmBackend>,
107 pub llm_model: String,
109 pub focused_panel: FocusedPanel,
111 pub tree_state: RefCell<TreeState<TreeNodeId>>,
113 pub tree_filter: Option<HunkFilter>,
116}
117
118impl App {
119 pub fn new(diff_data: DiffData, config: &crate::config::Config) -> Self {
121 let highlight_cache = HighlightCache::new(&diff_data);
122 Self {
123 diff_data,
124 ui_state: UiState {
125 selected_index: 0,
126 scroll_offset: 0,
127 collapsed: HashSet::new(),
128 viewport_height: 24, diff_view_width: Cell::new(80),
130 },
131 highlight_cache,
132 should_quit: false,
133 event_tx: None,
134 debounce_handle: None,
135 input_mode: InputMode::Normal,
136 search_query: String::new(),
137 active_filter: None,
138 semantic_groups: None,
139 grouping_status: GroupingStatus::Idle,
140 grouping_handle: None,
141 llm_backend: config.detect_backend(),
142 llm_model: config
143 .detect_backend()
144 .map(|b| config.model_for_backend(b).to_string())
145 .unwrap_or_default(),
146 focused_panel: FocusedPanel::DiffView,
147 tree_state: RefCell::new(TreeState::default()),
148 tree_filter: None,
149 }
150 }
151
152 pub fn update(&mut self, msg: Message) -> Option<Command> {
154 match msg {
155 Message::KeyPress(key) => self.handle_key(key),
156 Message::Resize(_w, h) => {
157 self.ui_state.viewport_height = h.saturating_sub(1);
158 None
159 }
160 Message::RefreshSignal => {
161 if let Some(handle) = self.debounce_handle.take() {
163 handle.abort();
164 }
165 if let Some(tx) = &self.event_tx {
167 let tx = tx.clone();
168 self.debounce_handle = Some(tokio::spawn(async move {
169 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
170 let _ = tx.send(Message::DebouncedRefresh).await;
171 }));
172 }
173 None
174 }
175 Message::DebouncedRefresh => {
176 self.debounce_handle = None;
177 Some(Command::SpawnDiffParse)
178 }
179 Message::DiffParsed(new_data, raw_diff) => {
180 self.apply_new_diff_data(new_data);
181 let hash = crate::cache::diff_hash(&raw_diff);
182 if let Some(cached) = crate::cache::load(hash) {
184 self.semantic_groups = Some(cached);
185 self.grouping_status = GroupingStatus::Done;
186 self.grouping_handle = None;
187 None
188 } else if let Some(backend) = self.llm_backend {
189 if let Some(handle) = self.grouping_handle.take() {
191 handle.abort();
192 }
193 self.grouping_status = GroupingStatus::Loading;
194 let summaries = crate::grouper::hunk_summaries(&self.diff_data);
195 Some(Command::SpawnGrouping {
196 backend,
197 model: self.llm_model.clone(),
198 summaries,
199 diff_hash: hash,
200 })
201 } else {
202 self.grouping_status = GroupingStatus::Idle;
203 None
204 }
205 }
206 Message::GroupingComplete(groups) => {
207 self.semantic_groups = Some(groups);
208 self.grouping_status = GroupingStatus::Done;
209 self.grouping_handle = None;
210 let mut ts = self.tree_state.borrow_mut();
212 *ts = TreeState::default();
213 ts.select_first();
214 drop(ts);
215 self.tree_filter = None;
217 None
218 }
219 Message::GroupingFailed(err) => {
220 tracing::warn!("Grouping failed: {}", err);
221 self.grouping_status = GroupingStatus::Error(err);
222 self.grouping_handle = None;
223 None }
225 }
226 }
227
228 fn apply_new_diff_data(&mut self, new_data: DiffData) {
230 let mut collapsed_files: HashSet<String> = HashSet::new();
232 let mut collapsed_hunks: HashSet<(String, usize)> = HashSet::new();
233
234 for node in &self.ui_state.collapsed {
235 match node {
236 NodeId::File(fi) => {
237 if let Some(file) = self.diff_data.files.get(*fi) {
238 collapsed_files.insert(file.target_file.clone());
239 }
240 }
241 NodeId::Hunk(fi, hi) => {
242 if let Some(file) = self.diff_data.files.get(*fi) {
243 collapsed_hunks.insert((file.target_file.clone(), *hi));
244 }
245 }
246 }
247 }
248
249 let selected_path = self.selected_file_path();
251
252 self.diff_data = new_data;
254 self.highlight_cache = HighlightCache::new(&self.diff_data);
255
256 self.ui_state.collapsed.clear();
258 for (fi, file) in self.diff_data.files.iter().enumerate() {
259 if collapsed_files.contains(&file.target_file) {
260 self.ui_state.collapsed.insert(NodeId::File(fi));
261 }
262 for (hi, _) in file.hunks.iter().enumerate() {
263 if collapsed_hunks.contains(&(file.target_file.clone(), hi)) {
264 self.ui_state.collapsed.insert(NodeId::Hunk(fi, hi));
265 }
266 }
267 }
268
269 if let Some(path) = selected_path {
271 let items = self.visible_items();
272 let restored = items.iter().position(|item| {
273 if let VisibleItem::FileHeader { file_idx } = item {
274 self.diff_data.files[*file_idx].target_file == path
275 } else {
276 false
277 }
278 });
279 if let Some(idx) = restored {
280 self.ui_state.selected_index = idx;
281 } else {
282 self.ui_state.selected_index = self
283 .ui_state
284 .selected_index
285 .min(items.len().saturating_sub(1));
286 }
287 } else {
288 let items_len = self.visible_items().len();
289 self.ui_state.selected_index = self
290 .ui_state
291 .selected_index
292 .min(items_len.saturating_sub(1));
293 }
294
295 self.adjust_scroll();
296 }
297
298 fn selected_file_path(&self) -> Option<String> {
300 let items = self.visible_items();
301 let item = items.get(self.ui_state.selected_index)?;
302 let fi = match item {
303 VisibleItem::FileHeader { file_idx } => *file_idx,
304 VisibleItem::HunkHeader { file_idx, .. } => *file_idx,
305 VisibleItem::DiffLine { file_idx, .. } => *file_idx,
306 };
307 self.diff_data.files.get(fi).map(|f| f.target_file.clone())
308 }
309
310 fn handle_key(&mut self, key: KeyEvent) -> Option<Command> {
312 match self.input_mode {
313 InputMode::Normal => self.handle_key_normal(key),
314 InputMode::Search => self.handle_key_search(key),
315 InputMode::Help => {
316 self.input_mode = InputMode::Normal;
318 None
319 }
320 }
321 }
322
323 fn handle_key_normal(&mut self, key: KeyEvent) -> Option<Command> {
325 match key.code {
327 KeyCode::Char('q') => return Some(Command::Quit),
328 KeyCode::Char('?') => {
329 self.input_mode = InputMode::Help;
330 return None;
331 }
332 KeyCode::Tab => {
333 self.focused_panel = match self.focused_panel {
334 FocusedPanel::FileTree => FocusedPanel::DiffView,
335 FocusedPanel::DiffView => FocusedPanel::FileTree,
336 };
337 return None;
338 }
339 KeyCode::Esc => {
340 if self.tree_filter.is_some() || self.active_filter.is_some() {
341 self.tree_filter = None;
342 self.active_filter = None;
343 self.ui_state.selected_index = 0;
344 self.adjust_scroll();
345 return None;
346 } else {
347 return Some(Command::Quit);
348 }
349 }
350 KeyCode::Char('/') => {
351 self.input_mode = InputMode::Search;
352 self.search_query.clear();
353 return None;
354 }
355 _ => {}
356 }
357
358 match self.focused_panel {
360 FocusedPanel::FileTree => self.handle_key_tree(key),
361 FocusedPanel::DiffView => self.handle_key_diff(key),
362 }
363 }
364
365 fn handle_key_tree(&mut self, key: KeyEvent) -> Option<Command> {
367 let mut ts = self.tree_state.borrow_mut();
368 match key.code {
369 KeyCode::Char('j') | KeyCode::Down => {
370 ts.key_down();
371 None
372 }
373 KeyCode::Char('k') | KeyCode::Up => {
374 ts.key_up();
375 None
376 }
377 KeyCode::Left => {
378 ts.key_left();
379 None
380 }
381 KeyCode::Right => {
382 ts.key_right();
383 None
384 }
385 KeyCode::Enter => {
386 let selected = ts.selected().to_vec();
387 drop(ts); if let Some(last) = selected.last() {
389 match last {
390 TreeNodeId::File(path) => {
391 self.select_tree_file(path);
392 }
393 TreeNodeId::Group(gi) => {
394 self.select_tree_group(*gi);
395 }
396 }
397 }
398 None
399 }
400 KeyCode::Char('g') => {
401 ts.select_first();
402 None
403 }
404 KeyCode::Char('G') => {
405 ts.select_last();
406 None
407 }
408 _ => None,
409 }
410 }
411
412 fn handle_key_diff(&mut self, key: KeyEvent) -> Option<Command> {
414 let items_len = self.visible_items().len();
415 if items_len == 0 {
416 return None;
417 }
418
419 match key.code {
420 KeyCode::Char('n') => {
422 self.jump_to_match(true);
423 None
424 }
425 KeyCode::Char('N') => {
426 self.jump_to_match(false);
427 None
428 }
429
430 KeyCode::Char('j') | KeyCode::Down => {
432 self.move_selection(1, items_len);
433 None
434 }
435 KeyCode::Char('k') | KeyCode::Up => {
436 self.move_selection(-1, items_len);
437 None
438 }
439 KeyCode::Char('g') => {
440 self.ui_state.selected_index = 0;
441 self.adjust_scroll();
442 None
443 }
444 KeyCode::Char('G') => {
445 self.ui_state.selected_index = items_len.saturating_sub(1);
446 self.adjust_scroll();
447 None
448 }
449 KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
450 let half_page = (self.ui_state.viewport_height / 2) as usize;
451 self.move_selection(half_page as isize, items_len);
452 None
453 }
454 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
455 let half_page = (self.ui_state.viewport_height / 2) as usize;
456 self.move_selection(-(half_page as isize), items_len);
457 None
458 }
459
460 KeyCode::Enter => {
462 self.toggle_collapse();
463 None
464 }
465
466 _ => None,
467 }
468 }
469
470 fn select_tree_file(&mut self, path: &str) {
473 let filter = self.hunk_filter_for_file(path);
474 self.tree_filter = Some(filter);
476 let items = self.visible_items();
478 let target_idx = items.iter().position(|item| {
479 if let VisibleItem::FileHeader { file_idx } = item {
480 self.diff_data.files[*file_idx]
481 .target_file
482 .trim_start_matches("b/")
483 == path
484 } else {
485 false
486 }
487 });
488 self.ui_state.selected_index = target_idx.unwrap_or(0);
489 self.ui_state.scroll_offset = self.ui_state.selected_index as u16;
491 }
492
493 fn select_tree_group(&mut self, group_idx: usize) {
495 let filter = self.hunk_filter_for_group(group_idx);
496 if filter.is_empty() {
497 self.tree_state.borrow_mut().toggle_selected();
498 return;
499 }
500 if self.tree_filter.as_ref() == Some(&filter) {
502 self.tree_filter = None;
503 } else {
504 self.tree_filter = Some(filter);
505 }
506 self.ui_state.selected_index = 0;
507 self.ui_state.scroll_offset = 0;
508 }
509
510 fn hunk_filter_for_file(&self, path: &str) -> HunkFilter {
513 if let Some(groups) = &self.semantic_groups {
514 for (gi, group) in groups.iter().enumerate() {
515 let has_file = group.changes().iter().any(|c| {
516 c.file == path || path.ends_with(c.file.as_str()) || c.file.ends_with(path)
517 });
518 if has_file {
519 return self.hunk_filter_for_group(gi);
520 }
521 }
522 return self.hunk_filter_for_other();
524 }
525 let mut filter = HunkFilter::new();
527 filter.insert(path.to_string(), HashSet::new());
528 filter
529 }
530
531 fn hunk_filter_for_group(&self, group_idx: usize) -> HunkFilter {
533 if let Some(groups) = &self.semantic_groups {
534 if let Some(group) = groups.get(group_idx) {
535 let mut filter = HunkFilter::new();
536 for change in &group.changes() {
537 if let Some(diff_path) = self.resolve_diff_path(&change.file) {
539 let hunk_set: HashSet<usize> = change.hunks.iter().copied().collect();
540 filter
541 .entry(diff_path)
542 .or_default()
543 .extend(hunk_set.iter());
544 }
545 }
546 return filter;
547 }
548 if group_idx >= groups.len() {
550 return self.hunk_filter_for_other();
551 }
552 }
553 HunkFilter::new()
554 }
555
556 fn hunk_filter_for_other(&self) -> HunkFilter {
558 let groups = match &self.semantic_groups {
559 Some(g) => g,
560 None => return HunkFilter::new(),
561 };
562
563 let mut grouped: HashMap<String, HashSet<usize>> = HashMap::new();
565 for group in groups {
566 for change in &group.changes() {
567 if let Some(dp) = self.resolve_diff_path(&change.file) {
568 grouped.entry(dp).or_default().extend(change.hunks.iter());
569 }
570 }
571 }
572
573 let mut filter = HunkFilter::new();
575 for file in &self.diff_data.files {
576 let dp = file.target_file.trim_start_matches("b/").to_string();
577 if let Some(grouped_hunks) = grouped.get(&dp) {
578 if grouped_hunks.is_empty() {
580 continue;
581 }
582 let ungrouped: HashSet<usize> = (0..file.hunks.len())
583 .filter(|hi| !grouped_hunks.contains(hi))
584 .collect();
585 if !ungrouped.is_empty() {
586 filter.insert(dp, ungrouped);
587 }
588 } else {
589 filter.insert(dp, HashSet::new());
591 }
592 }
593 filter
594 }
595
596 fn resolve_diff_path(&self, group_path: &str) -> Option<String> {
598 self.diff_data.files.iter().find_map(|f| {
599 let dp = f.target_file.trim_start_matches("b/");
600 if dp == group_path || dp.ends_with(group_path) {
601 Some(dp.to_string())
602 } else {
603 None
604 }
605 })
606 }
607
608 fn handle_key_search(&mut self, key: KeyEvent) -> Option<Command> {
610 match key.code {
611 KeyCode::Esc => {
612 self.input_mode = InputMode::Normal;
613 self.search_query.clear();
614 self.active_filter = None;
615 None
616 }
617 KeyCode::Enter => {
618 self.input_mode = InputMode::Normal;
619 self.active_filter = if self.search_query.is_empty() {
620 None
621 } else {
622 Some(self.search_query.clone())
623 };
624 self.ui_state.selected_index = 0;
625 self.adjust_scroll();
626 None
627 }
628 KeyCode::Backspace => {
629 self.search_query.pop();
630 None
631 }
632 KeyCode::Char(c) => {
633 self.search_query.push(c);
634 None
635 }
636 _ => None,
637 }
638 }
639
640 fn jump_to_match(&mut self, forward: bool) {
642 if self.active_filter.is_none() {
643 return;
644 }
645 let items = self.visible_items();
646 if items.is_empty() {
647 return;
648 }
649
650 let pattern = self.active_filter.as_ref().unwrap().to_lowercase();
651 let len = items.len();
652 let start = self.ui_state.selected_index;
653
654 for offset in 1..=len {
656 let idx = if forward {
657 (start + offset) % len
658 } else {
659 (start + len - offset) % len
660 };
661 if let VisibleItem::FileHeader { file_idx } = &items[idx] {
662 let path = &self.diff_data.files[*file_idx].target_file;
663 if path.to_lowercase().contains(&pattern) {
664 self.ui_state.selected_index = idx;
665 self.adjust_scroll();
666 return;
667 }
668 }
669 }
670 }
671
672 fn move_selection(&mut self, delta: isize, items_len: usize) {
674 let max_idx = items_len.saturating_sub(1);
675 let new_idx = if delta > 0 {
676 (self.ui_state.selected_index + delta as usize).min(max_idx)
677 } else {
678 self.ui_state.selected_index.saturating_sub((-delta) as usize)
679 };
680 self.ui_state.selected_index = new_idx;
681 self.adjust_scroll();
682 }
683
684 fn toggle_collapse(&mut self) {
686 let items = self.visible_items();
687 if let Some(item) = items.get(self.ui_state.selected_index) {
688 let node_id = match item {
689 VisibleItem::FileHeader { file_idx } => Some(NodeId::File(*file_idx)),
690 VisibleItem::HunkHeader { file_idx, hunk_idx } => {
691 Some(NodeId::Hunk(*file_idx, *hunk_idx))
692 }
693 VisibleItem::DiffLine { .. } => None, };
695
696 if let Some(id) = node_id {
697 if self.ui_state.collapsed.contains(&id) {
698 self.ui_state.collapsed.remove(&id);
699 } else {
700 self.ui_state.collapsed.insert(id);
701 }
702
703 let new_items_len = self.visible_items().len();
705 if self.ui_state.selected_index >= new_items_len {
706 self.ui_state.selected_index = new_items_len.saturating_sub(1);
707 }
708 self.adjust_scroll();
709 }
710 }
711 }
712
713 fn item_char_width(&self, item: &VisibleItem) -> usize {
715 match item {
716 VisibleItem::FileHeader { file_idx } => {
717 let file = &self.diff_data.files[*file_idx];
718 let name = if file.is_rename {
719 format!(
720 "renamed: {} -> {}",
721 file.source_file.trim_start_matches("a/"),
722 file.target_file.trim_start_matches("b/")
723 )
724 } else {
725 file.target_file.trim_start_matches("b/").to_string()
726 };
727 3 + name.len()
729 + 1
730 + format!("+{}", file.added_count).len()
731 + format!(" -{}", file.removed_count).len()
732 }
733 VisibleItem::HunkHeader { file_idx, hunk_idx } => {
734 let hunk = &self.diff_data.files[*file_idx].hunks[*hunk_idx];
735 5 + hunk.header.len()
737 }
738 VisibleItem::DiffLine {
739 file_idx,
740 hunk_idx,
741 line_idx,
742 } => {
743 let line =
744 &self.diff_data.files[*file_idx].hunks[*hunk_idx].lines[*line_idx];
745 12 + line.content.len()
747 }
748 }
749 }
750
751 pub fn item_visual_rows(&self, item: &VisibleItem, width: u16) -> usize {
753 if width == 0 {
754 return 1;
755 }
756 let char_width = self.item_char_width(item);
757 char_width.div_ceil(width as usize).max(1)
758 }
759
760 fn adjust_scroll(&mut self) {
763 let width = self.ui_state.diff_view_width.get();
764 let viewport = self.ui_state.viewport_height as usize;
765 let items = self.visible_items();
766 let selected = self.ui_state.selected_index;
767
768 if items.is_empty() || viewport == 0 {
769 self.ui_state.scroll_offset = 0;
770 return;
771 }
772
773 let scroll = self.ui_state.scroll_offset as usize;
774
775 if selected < scroll {
777 self.ui_state.scroll_offset = selected as u16;
778 return;
779 }
780
781 let mut rows = 0usize;
783 for (i, item) in items.iter().enumerate().take(selected + 1).skip(scroll) {
784 rows += self.item_visual_rows(item, width);
785 if rows > viewport && i < selected {
786 break;
787 }
788 }
789
790 if rows <= viewport {
791 return;
792 }
793
794 let selected_height = self.item_visual_rows(&items[selected], width);
796 if selected_height >= viewport {
797 self.ui_state.scroll_offset = selected as u16;
798 return;
799 }
800
801 let mut remaining = viewport - selected_height;
802 let mut new_scroll = selected;
803 for i in (0..selected).rev() {
804 let h = self.item_visual_rows(&items[i], width);
805 if h > remaining {
806 break;
807 }
808 remaining -= h;
809 new_scroll = i;
810 }
811 self.ui_state.scroll_offset = new_scroll as u16;
812 }
813
814 pub fn visible_items(&self) -> Vec<VisibleItem> {
817 let filter_lower = self
818 .active_filter
819 .as_ref()
820 .map(|f| f.to_lowercase());
821
822 let mut items = Vec::new();
823 for (fi, file) in self.diff_data.files.iter().enumerate() {
824 let file_path = file.target_file.trim_start_matches("b/");
825
826 if let Some(ref pattern) = filter_lower {
828 if !file.target_file.to_lowercase().contains(pattern) {
829 continue;
830 }
831 }
832
833 let allowed_hunks: Option<&HashSet<usize>> =
835 self.tree_filter.as_ref().and_then(|f| f.get(file_path));
836
837 if self.tree_filter.is_some() && allowed_hunks.is_none() {
839 continue;
840 }
841
842 items.push(VisibleItem::FileHeader { file_idx: fi });
843 if !self.ui_state.collapsed.contains(&NodeId::File(fi)) {
844 for (hi, hunk) in file.hunks.iter().enumerate() {
845 if let Some(hunk_set) = allowed_hunks {
848 if !hunk_set.is_empty() && !hunk_set.contains(&hi) {
849 continue;
850 }
851 }
852
853 items.push(VisibleItem::HunkHeader {
854 file_idx: fi,
855 hunk_idx: hi,
856 });
857 if !self.ui_state.collapsed.contains(&NodeId::Hunk(fi, hi)) {
858 for (li, _line) in hunk.lines.iter().enumerate() {
859 items.push(VisibleItem::DiffLine {
860 file_idx: fi,
861 hunk_idx: hi,
862 line_idx: li,
863 });
864 }
865 }
866 }
867 }
868 }
869 items
870 }
871
872 pub fn view(&self, frame: &mut ratatui::Frame) {
874 crate::ui::draw(self, frame);
875 }
876}