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