1use std::path::PathBuf;
2
3use ratatui::widgets::ListState;
4use std::sync::mpsc;
5
6use gitkraft_core::*;
7
8macro_rules! bg_task {
18 ($self:expr, $status:expr, $variant:path, |$rp:ident| $body:expr) => {{
19 let $rp = match $self.tab().repo_path.clone() {
20 Some(p) => p,
21 None => return,
22 };
23 $self.tab_mut().is_loading = true;
24 $self.tab_mut().status_message = Some($status.into());
25 let tx = $self.bg_tx.clone();
26 std::thread::spawn(move || {
27 let _ = tx.send($variant((|| $body)()));
28 });
29 }};
30}
31
32macro_rules! bg_op {
40 ($self:expr, $status:expr, staging, |$rp:ident| $body:expr) => {
41 bg_op!(@inner $self, $status, false, true, |$rp| $body)
42 };
43 ($self:expr, $status:expr, refresh, |$rp:ident| $body:expr) => {
44 bg_op!(@inner $self, $status, true, false, |$rp| $body)
45 };
46 (@inner $self:expr, $status:expr, $nr:expr, $nsr:expr, |$rp:ident| $body:expr) => {{
47 let $rp = match $self.tab().repo_path.clone() {
48 Some(p) => p,
49 None => return,
50 };
51 $self.tab_mut().is_loading = true;
52 $self.tab_mut().status_message = Some($status.into());
53 let tx = $self.bg_tx.clone();
54 std::thread::spawn(move || {
55 let res: Result<String, String> = (|| $body)();
56 let _ = tx.send(BackgroundResult::OperationDone {
57 ok_message: res.as_ref().ok().cloned(),
58 err_message: res.err(),
59 needs_refresh: $nr,
60 needs_staging_refresh: $nsr,
61 });
62 });
63 }};
64}
65
66pub type RepoPayload = gitkraft_core::RepoSnapshot;
71
72#[derive(Debug)]
74pub enum BackgroundResult {
75 RepoLoaded {
78 path: PathBuf,
79 result: Result<RepoPayload, String>,
80 },
81 FetchDone(Result<(), String>),
83 CommitDiffLoaded(Result<Vec<DiffInfo>, String>),
85 StagingRefreshed(Result<StagingPayload, String>),
87 OperationDone {
90 ok_message: Option<String>,
91 err_message: Option<String>,
92 needs_refresh: bool,
94 needs_staging_refresh: bool,
96 },
97 CommitFileListLoaded(Result<Vec<gitkraft_core::DiffFileEntry>, String>),
99 SingleFileDiffLoaded(Result<(usize, gitkraft_core::DiffInfo), String>),
101 SearchResults(Result<Vec<gitkraft_core::CommitInfo>, String>),
103 CommitRangeDiffLoaded(Result<Vec<gitkraft_core::DiffInfo>, String>),
105 FileHistoryLoaded {
107 path: String,
108 commits: Vec<gitkraft_core::CommitInfo>,
109 },
110 FileBlameLoaded {
112 path: String,
113 lines: Vec<gitkraft_core::BlameLine>,
114 },
115}
116
117#[derive(Debug)]
119pub struct StagingPayload {
120 pub unstaged: Vec<DiffInfo>,
121 pub staged: Vec<DiffInfo>,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq)]
127pub enum AppScreen {
128 Welcome,
129 DirBrowser,
130 Main,
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum ActivePane {
135 Branches,
136 CommitLog,
137 DiffView,
138 Staging,
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub enum InputMode {
143 Normal,
144 Input,
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub enum InputPurpose {
149 None,
150 CommitMessage,
151 BranchName,
152 RepoPath,
153 SearchQuery,
154 StashMessage,
155 CommitActionInput1,
157 CommitActionInput2,
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub enum StagingFocus {
164 Unstaged,
165 Staged,
166}
167
168#[derive(Debug, Clone, PartialEq, Eq)]
170pub enum DiffSubPane {
171 FileList,
173 Content,
175}
176
177pub struct RepoTab {
181 pub repo_path: Option<PathBuf>,
182 pub repo_info: Option<RepoInfo>,
183
184 pub branches: Vec<BranchInfo>,
185 pub branch_list_state: ListState,
186
187 pub commits: Vec<CommitInfo>,
188 pub graph_rows: Vec<gitkraft_core::GraphRow>,
189 pub commit_list_state: ListState,
190
191 pub unstaged_changes: Vec<DiffInfo>,
192 pub staged_changes: Vec<DiffInfo>,
193 pub unstaged_list_state: ListState,
194 pub staged_list_state: ListState,
195 pub staging_focus: StagingFocus,
196 pub selected_diff: Option<DiffInfo>,
197 pub diff_scroll: u16,
198 pub diff_sub_pane: DiffSubPane,
200 pub anchor_file_index: Option<usize>,
202 pub selected_file_indices: std::collections::HashSet<usize>,
204 pub commit_diffs: std::collections::HashMap<usize, DiffInfo>,
206 pub commit_diff_file_index: usize,
208 pub commit_files: Vec<gitkraft_core::DiffFileEntry>,
210 pub selected_commit_oid: Option<String>,
212
213 pub stashes: Vec<StashEntry>,
214 pub stash_list_state: ListState,
215 pub remotes: Vec<RemoteInfo>,
216
217 pub search_query: String,
219 pub search_active: bool,
221 pub search_results: Vec<CommitInfo>,
223
224 pub stash_message_buffer: String,
226
227 pub status_message: Option<String>,
228 pub error_message: Option<String>,
229
230 pub is_loading: bool,
232
233 pub confirm_discard: bool,
236
237 pub selected_unstaged: std::collections::HashSet<usize>,
239 pub selected_staged: std::collections::HashSet<usize>,
241 pub anchor_unstaged: Option<usize>,
243 pub anchor_staged: Option<usize>,
245
246 pub anchor_commit_index: Option<usize>,
248 pub selected_commits: Vec<usize>,
250 pub commit_range_diffs: Vec<DiffInfo>,
252
253 pub commit_action_items: Vec<gitkraft_core::CommitActionKind>,
256 pub commit_action_cursor: usize,
258 pub pending_commit_action_oid: Option<String>,
260 pub pending_action_kind: Option<gitkraft_core::CommitActionKind>,
262 pub action_input1: String,
264
265 pub file_history_path: Option<String>,
267 pub file_history_commits: Vec<gitkraft_core::CommitInfo>,
269 pub file_history_cursor: usize,
271
272 pub blame_path: Option<String>,
274 pub blame_lines: Vec<gitkraft_core::BlameLine>,
276 pub blame_scroll: u16,
278
279 pub confirm_delete_file: Option<String>,
281}
282
283impl RepoTab {
284 #[must_use]
285 pub fn new() -> Self {
286 Self {
287 repo_path: None,
288 repo_info: None,
289
290 branches: Vec::new(),
291 branch_list_state: ListState::default(),
292
293 commits: Vec::new(),
294 graph_rows: Vec::new(),
295 commit_list_state: ListState::default(),
296
297 unstaged_changes: Vec::new(),
298 staged_changes: Vec::new(),
299 unstaged_list_state: ListState::default(),
300 staged_list_state: ListState::default(),
301 staging_focus: StagingFocus::Unstaged,
302 selected_diff: None,
303 diff_scroll: 0,
304 diff_sub_pane: DiffSubPane::FileList,
305 anchor_file_index: None,
306 selected_file_indices: std::collections::HashSet::new(),
307 commit_diffs: std::collections::HashMap::new(),
308 commit_diff_file_index: 0,
309 commit_files: Vec::new(),
310 selected_commit_oid: None,
311
312 stashes: Vec::new(),
313 stash_list_state: ListState::default(),
314 remotes: Vec::new(),
315
316 stash_message_buffer: String::new(),
317
318 search_query: String::new(),
319 search_active: false,
320 search_results: Vec::new(),
321
322 status_message: None,
323 error_message: None,
324
325 is_loading: false,
326
327 confirm_discard: false,
328
329 selected_unstaged: std::collections::HashSet::new(),
330 selected_staged: std::collections::HashSet::new(),
331 anchor_unstaged: None,
332 anchor_staged: None,
333
334 anchor_commit_index: None,
335 selected_commits: Vec::new(),
336 commit_range_diffs: Vec::new(),
337
338 commit_action_items: Vec::new(),
339 commit_action_cursor: 0,
340 pending_commit_action_oid: None,
341 pending_action_kind: None,
342 action_input1: String::new(),
343
344 file_history_path: None,
345 file_history_commits: Vec::new(),
346 file_history_cursor: 0,
347 blame_path: None,
348 blame_lines: Vec::new(),
349 blame_scroll: 0,
350 confirm_delete_file: None,
351 }
352 }
353
354 pub fn display_name(&self) -> String {
357 match &self.repo_path {
358 Some(p) => p
359 .file_name()
360 .map(|n| n.to_string_lossy().into_owned())
361 .unwrap_or_else(|| "New Tab".into()),
362 None => "New Tab".into(),
363 }
364 }
365}
366
367impl Default for RepoTab {
368 fn default() -> Self {
369 Self::new()
370 }
371}
372
373pub struct App {
376 pub should_quit: bool,
377 pub screen: AppScreen,
378 pub active_pane: ActivePane,
379 pub input_mode: InputMode,
380 pub input_purpose: InputPurpose,
381 pub tick_count: u64,
382
383 pub bg_rx: mpsc::Receiver<BackgroundResult>,
385 pub(crate) bg_tx: mpsc::Sender<BackgroundResult>,
387
388 pub input_buffer: String,
389
390 pub show_theme_panel: bool,
392 pub show_options_panel: bool,
394 pub editor: gitkraft_core::Editor,
396 pub show_editor_panel: bool,
398 pub editor_list_state: ListState,
400 pub current_theme_index: usize,
402 pub theme_list_state: ListState,
404
405 pub recent_repos: Vec<gitkraft_core::RepoHistoryEntry>,
407
408 pub browser_dir: PathBuf,
410 pub browser_entries: Vec<std::path::PathBuf>,
412 pub browser_list_state: ListState,
414 pub browser_return_screen: AppScreen,
416
417 pub tabs: Vec<RepoTab>,
419 pub active_tab_index: usize,
421
422 pub pending_editor_open: Option<Vec<std::path::PathBuf>>,
426
427 pub last_auto_refresh: std::time::Instant,
429}
430
431impl App {
432 #[must_use]
435 pub fn new() -> Self {
436 let settings =
437 gitkraft_core::features::persistence::load_tui_settings().unwrap_or_default();
438
439 let theme_index = theme_name_to_index(settings.theme_name.as_deref().unwrap_or(""));
440
441 let recent_repos = settings.recent_repos;
442
443 let (bg_tx, bg_rx) = mpsc::channel();
444
445 Self {
446 should_quit: false,
447 screen: AppScreen::Welcome,
448 active_pane: ActivePane::Branches,
449 input_mode: InputMode::Normal,
450 input_purpose: InputPurpose::None,
451 tick_count: 0,
452
453 bg_rx,
454 bg_tx,
455
456 input_buffer: String::new(),
457
458 show_theme_panel: false,
459 show_options_panel: false,
460 editor: settings
461 .editor_name
462 .as_deref()
463 .map(|name| {
464 gitkraft_core::EDITOR_NAMES
465 .iter()
466 .position(|n| n.eq_ignore_ascii_case(name))
467 .map(gitkraft_core::Editor::from_index)
468 .unwrap_or_else(|| {
469 if name.eq_ignore_ascii_case("none") {
470 gitkraft_core::Editor::None
471 } else {
472 gitkraft_core::Editor::Custom(name.to_string())
473 }
474 })
475 })
476 .unwrap_or(gitkraft_core::Editor::None),
477 show_editor_panel: false,
478 editor_list_state: {
479 let mut s = ListState::default();
480 s.select(Some(0));
481 s
482 },
483 current_theme_index: theme_index,
484 theme_list_state: {
485 let mut s = ListState::default();
486 s.select(Some(theme_index));
487 s
488 },
489
490 recent_repos,
491
492 browser_dir: dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")),
493 browser_entries: Vec::new(),
494 browser_list_state: ListState::default(),
495 browser_return_screen: AppScreen::Welcome,
496
497 tabs: vec![RepoTab::new()],
498 active_tab_index: 0,
499
500 pending_editor_open: None,
501
502 last_auto_refresh: std::time::Instant::now(),
503 }
504 }
505
506 #[inline]
510 pub fn tab(&self) -> &RepoTab {
511 &self.tabs[self.active_tab_index]
512 }
513
514 #[inline]
516 pub fn tab_mut(&mut self) -> &mut RepoTab {
517 &mut self.tabs[self.active_tab_index]
518 }
519
520 pub fn new_tab(&mut self) {
524 self.tabs.push(RepoTab::new());
525 self.active_tab_index = self.tabs.len() - 1;
526 self.screen = AppScreen::Welcome;
527 if let Ok(settings) = gitkraft_core::features::persistence::load_tui_settings() {
529 self.recent_repos = settings.recent_repos;
530 }
531 self.save_session();
532 }
533
534 pub fn close_tab(&mut self) {
536 if self.tabs.len() <= 1 {
537 self.tabs[0] = RepoTab::new();
538 self.active_tab_index = 0;
539 } else {
540 self.tabs.remove(self.active_tab_index);
541 if self.active_tab_index >= self.tabs.len() {
542 self.active_tab_index = self.tabs.len() - 1;
543 }
544 }
545 self.save_session();
546 }
547
548 pub fn next_tab(&mut self) {
550 if !self.tabs.is_empty() {
551 self.active_tab_index = (self.active_tab_index + 1) % self.tabs.len();
552 if self.tabs[self.active_tab_index].repo_path.is_some() {
554 self.screen = AppScreen::Main;
555 } else {
556 self.screen = AppScreen::Welcome;
557 }
558 }
559 }
560
561 pub fn prev_tab(&mut self) {
563 if !self.tabs.is_empty() {
564 if self.active_tab_index == 0 {
565 self.active_tab_index = self.tabs.len() - 1;
566 } else {
567 self.active_tab_index -= 1;
568 }
569 if self.tabs[self.active_tab_index].repo_path.is_some() {
571 self.screen = AppScreen::Main;
572 } else {
573 self.screen = AppScreen::Welcome;
574 }
575 }
576 }
577}
578
579impl Default for App {
580 fn default() -> Self {
581 Self::new()
582 }
583}
584
585impl App {
586 pub fn cycle_theme_next(&mut self) {
589 let count = 27; self.current_theme_index = (self.current_theme_index + 1) % count;
591 self.theme_list_state.select(Some(self.current_theme_index));
592 self.tab_mut().status_message = Some(format!("Theme: {}", self.current_theme_name()));
593 }
594
595 pub fn cycle_theme_prev(&mut self) {
596 let count = 27;
597 if self.current_theme_index == 0 {
598 self.current_theme_index = count - 1;
599 } else {
600 self.current_theme_index -= 1;
601 }
602 self.theme_list_state.select(Some(self.current_theme_index));
603 self.tab_mut().status_message = Some(format!("Theme: {}", self.current_theme_name()));
604 }
605
606 pub fn current_theme_name(&self) -> &'static str {
607 gitkraft_core::THEME_NAMES
608 .get(self.current_theme_index)
609 .copied()
610 .unwrap_or("Default")
611 }
612
613 pub fn theme(&self) -> crate::features::theme::palette::UiTheme {
615 crate::features::theme::palette::theme_for_index(self.current_theme_index)
616 }
617
618 pub fn save_theme(&self) {
620 let _ = gitkraft_core::features::persistence::save_theme_tui(self.current_theme_name());
621 }
622
623 pub fn save_session(&self) {
625 let paths: Vec<std::path::PathBuf> = self
626 .tabs
627 .iter()
628 .filter_map(|t| t.repo_path.clone())
629 .collect();
630 let active = self.active_tab_index;
631 let _ = gitkraft_core::features::persistence::save_session_tui(&paths, active);
632 }
633
634 pub fn open_repo(&mut self, path: PathBuf) {
637 self.tab_mut().error_message = None;
638 self.tab_mut().status_message = Some("Opening repository…".into());
639 self.tab_mut().is_loading = true;
640 self.tab_mut().repo_path = Some(path.clone());
641 self.screen = AppScreen::Main;
642
643 let tx = self.bg_tx.clone();
644 std::thread::spawn(move || {
645 let result = load_repo_blocking(&path);
646 let _ = tx.send(BackgroundResult::RepoLoaded { path, result });
647 });
648 self.save_session();
649 }
650
651 pub fn refresh(&mut self) {
652 self.tab_mut().error_message = None;
653 self.tab_mut().is_loading = true;
654 self.tab_mut().status_message = Some("Refreshing…".into());
655
656 let path = match self.tab().repo_path.clone() {
657 Some(p) => p,
658 None => {
659 self.tab_mut().error_message = Some("No repository open".into());
660 self.tab_mut().is_loading = false;
661 return;
662 }
663 };
664
665 let tx = self.bg_tx.clone();
666 std::thread::spawn(move || {
667 let result = load_repo_blocking(&path);
668 let _ = tx.send(BackgroundResult::RepoLoaded { path, result });
669 });
670 }
671
672 pub fn poll_background(&mut self) {
675 while let Ok(result) = self.bg_rx.try_recv() {
676 match result {
677 BackgroundResult::RepoLoaded {
678 path: loaded_path,
679 result: res,
680 } => {
681 let tab_idx = self
683 .tabs
684 .iter()
685 .position(|t| t.repo_path.as_ref() == Some(&loaded_path))
686 .unwrap_or(self.active_tab_index);
687
688 self.tabs[tab_idx].is_loading = false;
689 match res {
690 Ok(payload) => {
691 let canonical = payload.info.workdir.clone().unwrap_or_else(|| {
692 self.tabs[tab_idx].repo_path.clone().unwrap_or_default()
693 });
694 self.tabs[tab_idx].repo_path = Some(canonical.clone());
695
696 let _ = gitkraft_core::features::persistence::record_repo_opened_tui(
698 &canonical,
699 );
700 if let Ok(settings) =
701 gitkraft_core::features::persistence::load_tui_settings()
702 {
703 self.recent_repos = settings.recent_repos;
704 }
705
706 let tab = &mut self.tabs[tab_idx];
707 tab.repo_info = Some(payload.info);
708 tab.branches = payload.branches;
709 clamp_list_state(&mut tab.branch_list_state, tab.branches.len());
710 tab.graph_rows = payload.graph_rows;
711 tab.commits = payload.commits;
712 clamp_list_state(&mut tab.commit_list_state, tab.commits.len());
713 tab.unstaged_changes = payload.unstaged;
714 clamp_list_state(
715 &mut tab.unstaged_list_state,
716 tab.unstaged_changes.len(),
717 );
718 tab.staged_changes = payload.staged;
719 clamp_list_state(&mut tab.staged_list_state, tab.staged_changes.len());
720 tab.stashes = payload.stashes;
721 clamp_list_state(&mut tab.stash_list_state, tab.stashes.len());
722 tab.remotes = payload.remotes;
723 tab.status_message = Some("Repository loaded".into());
724 self.screen = AppScreen::Main;
725 self.save_session();
726 }
727 Err(e) => {
728 self.tabs[tab_idx].error_message = Some(e);
729 self.tabs[tab_idx].status_message = None;
730 }
731 }
732 }
733 BackgroundResult::FetchDone(res) => {
734 self.tab_mut().is_loading = false;
735 match res {
736 Ok(()) => {
737 self.tab_mut().status_message = Some("Fetched from origin".into());
738 self.refresh();
739 }
740 Err(e) => self.tab_mut().error_message = Some(format!("fetch: {e}")),
741 }
742 }
743 BackgroundResult::CommitDiffLoaded(res) => {
744 self.tab_mut().is_loading = false;
745 match res {
746 Ok(diffs) => {
747 if diffs.is_empty() {
748 let tab = self.tab_mut();
749 tab.selected_diff = None;
750 tab.commit_diffs.clear();
751 tab.commit_diff_file_index = 0;
752 tab.status_message = Some("No changes in this commit".into());
753 } else {
754 let tab = self.tab_mut();
755 tab.commit_diffs = diffs
756 .iter()
757 .enumerate()
758 .map(|(i, d)| (i, d.clone()))
759 .collect();
760 tab.commit_diff_file_index = 0;
761 tab.selected_diff = Some(diffs[0].clone());
762 tab.diff_scroll = 0;
763 if diffs.len() > 1 {
764 tab.status_message = Some(format!(
765 "Showing file 1/{} — use h/l to switch files",
766 diffs.len()
767 ));
768 }
769 }
770 }
771 Err(e) => self.tab_mut().error_message = Some(format!("commit diff: {e}")),
772 }
773 }
774 BackgroundResult::CommitFileListLoaded(res) => {
775 self.tab_mut().is_loading = false;
776 match res {
777 Ok(files) => {
778 let count = files.len();
779 let tab = self.tab_mut();
780 tab.commit_files = files;
781 tab.commit_diffs.clear();
782 tab.commit_diff_file_index = 0;
783 tab.selected_diff = None;
784 tab.diff_scroll = 0;
785 tab.diff_sub_pane = DiffSubPane::FileList;
786 tab.selected_file_indices.clear();
787
788 if count == 0 {
789 tab.status_message = Some("No changes in this commit".into());
790 } else {
791 tab.status_message = Some(format!("{count} file(s) changed"));
792 tab.selected_file_indices.insert(0);
793 let first_path = tab.commit_files[0].display_path().to_string();
795 self.load_single_file_diff(0, first_path);
796 }
797 }
798 Err(e) => self.tab_mut().error_message = Some(format!("file list: {e}")),
799 }
800 }
801 BackgroundResult::SingleFileDiffLoaded(res) => {
802 self.tab_mut().is_loading = false;
803 match res {
804 Ok((file_index, diff)) => {
805 let tab = self.tab_mut();
806 tab.commit_diffs.insert(file_index, diff.clone());
807 let is_multi = tab.selected_file_indices.len() > 1;
810 if !is_multi && file_index == tab.commit_diff_file_index {
811 tab.selected_diff = Some(diff);
812 tab.diff_scroll = 0;
813 }
814 if tab.commit_files.len() > 1 {
815 let sel_count = tab.selected_file_indices.len();
816 if sel_count > 1 {
817 tab.status_message = Some(format!(
818 "{sel_count} files selected — use Shift+↑/↓ to adjust"
819 ));
820 } else {
821 tab.status_message = Some(format!(
822 "File {}/{} — use h/l or ↑/↓ to switch",
823 file_index + 1,
824 tab.commit_files.len()
825 ));
826 }
827 }
828 }
829 Err(e) => self.tab_mut().error_message = Some(format!("file diff: {e}")),
830 }
831 }
832 BackgroundResult::StagingRefreshed(res) => {
833 self.tab_mut().is_loading = false;
834 match res {
835 Ok(payload) => self.apply_staging_payload(payload),
836 Err(e) => {
837 self.tab_mut().error_message = Some(format!("staging refresh: {e}"))
838 }
839 }
840 }
841 BackgroundResult::OperationDone {
842 ok_message,
843 err_message,
844 needs_refresh,
845 needs_staging_refresh,
846 } => {
847 self.tab_mut().is_loading = false;
848 if let Some(msg) = err_message {
849 self.tab_mut().error_message = Some(msg);
850 } else if let Some(msg) = ok_message {
851 self.tab_mut().status_message = Some(msg);
852 }
853 if needs_refresh {
854 self.refresh();
855 } else if needs_staging_refresh {
856 self.refresh_staging();
857 }
858 }
859 BackgroundResult::SearchResults(res) => match res {
860 Ok(results) => {
861 self.tab_mut().search_results = results;
862 let count = self.tab().search_results.len();
863 self.tab_mut().status_message = Some(format!("{count} result(s) found"));
864 }
865 Err(e) => {
866 self.tab_mut().error_message = Some(format!("Search failed: {e}"));
867 }
868 },
869 BackgroundResult::CommitRangeDiffLoaded(res) => {
870 self.tab_mut().is_loading = false;
871 match res {
872 Ok(diffs) => {
873 let count = self.tab().selected_commits.len();
874 let tab = self.tab_mut();
875 tab.commit_range_diffs = diffs;
876 tab.diff_scroll = 0;
877 tab.status_message = Some(format!(
878 "Combined diff for {count} commits — use j/k to scroll"
879 ));
880 }
881 Err(e) => {
882 self.tab_mut().error_message = Some(format!("Range diff: {e}"));
883 }
884 }
885 }
886
887 BackgroundResult::FileHistoryLoaded { path, commits } => {
888 let count = commits.len();
889 let file_name = path.rsplit('/').next().unwrap_or(&path).to_string();
890 let tab = self.tab_mut();
891 tab.file_history_path = Some(path);
892 tab.file_history_commits = commits;
893 tab.file_history_cursor = 0;
894 tab.status_message = Some(format!("History: {file_name} ({count} commits)"));
895 }
896
897 BackgroundResult::FileBlameLoaded { path, lines } => {
898 let count = lines.len();
899 let file_name = path.rsplit('/').next().unwrap_or(&path).to_string();
900 let tab = self.tab_mut();
901 tab.blame_path = Some(path);
902 tab.blame_lines = lines;
903 tab.blame_scroll = 0;
904 tab.status_message = Some(format!("Blame: {file_name} ({count} lines)"));
905 }
906 }
907 }
908 }
909
910 pub fn maybe_auto_refresh(&mut self) {
913 if self.tab().repo_path.is_some()
914 && !self.tab().is_loading
915 && self.last_auto_refresh.elapsed() >= std::time::Duration::from_secs(3)
916 {
917 self.last_auto_refresh = std::time::Instant::now();
918 self.refresh_staging();
919 }
920 }
921
922 pub fn refresh_staging(&mut self) {
923 let repo_path = match self.tab().repo_path.clone() {
924 Some(p) => p,
925 None => {
926 self.tab_mut().error_message = Some("No repository open".into());
927 return;
928 }
929 };
930 let tx = self.bg_tx.clone();
931 std::thread::spawn(move || {
932 let res = (|| {
933 let repo = open_repo_str(&repo_path)?;
934 let unstaged = gitkraft_core::features::diff::get_working_dir_diff(&repo)
935 .map_err(|e| e.to_string())?;
936 let staged = gitkraft_core::features::diff::get_staged_diff(&repo)
937 .map_err(|e| e.to_string())?;
938 Ok::<_, String>(StagingPayload { unstaged, staged })
939 })();
940 let _ = tx.send(BackgroundResult::StagingRefreshed(res));
941 });
942 }
943
944 fn apply_staging_payload(&mut self, payload: StagingPayload) {
945 self.tab_mut().selected_unstaged.clear();
946 self.tab_mut().selected_staged.clear();
947 let tab = self.tab_mut();
948 tab.unstaged_changes = payload.unstaged;
949 if tab.unstaged_changes.is_empty() {
950 tab.unstaged_list_state.select(None);
951 } else if tab.unstaged_list_state.selected().is_none() {
952 tab.unstaged_list_state.select(Some(0));
953 } else if let Some(i) = tab.unstaged_list_state.selected() {
954 if i >= tab.unstaged_changes.len() {
955 tab.unstaged_list_state
956 .select(Some(tab.unstaged_changes.len() - 1));
957 }
958 }
959
960 tab.staged_changes = payload.staged;
961 if tab.staged_changes.is_empty() {
962 tab.staged_list_state.select(None);
963 } else if tab.staged_list_state.selected().is_none() {
964 tab.staged_list_state.select(Some(0));
965 } else if let Some(i) = tab.staged_list_state.selected() {
966 if i >= tab.staged_changes.len() {
967 tab.staged_list_state
968 .select(Some(tab.staged_changes.len() - 1));
969 }
970 }
971 }
972
973 pub fn stage_selected(&mut self) {
976 let idx = match self.tab().unstaged_list_state.selected() {
977 Some(i) => i,
978 None => {
979 self.tab_mut().status_message = Some("No unstaged file selected".into());
980 return;
981 }
982 };
983 let file_path = self.unstaged_file_path(idx);
984 bg_op!(self, "Staging…", staging, |repo_path| {
985 let repo = open_repo_str(&repo_path)?;
986 gitkraft_core::features::staging::stage_file(&repo, &file_path)
987 .map_err(|e| format!("stage: {e}"))?;
988 Ok(format!("Staged: {file_path}"))
989 });
990 }
991
992 pub fn unstage_selected(&mut self) {
993 let idx = match self.tab().staged_list_state.selected() {
994 Some(i) => i,
995 None => {
996 self.tab_mut().status_message = Some("No staged file selected".into());
997 return;
998 }
999 };
1000 let file_path = self.staged_file_path(idx);
1001 bg_op!(self, "Unstaging…", staging, |repo_path| {
1002 let repo = open_repo_str(&repo_path)?;
1003 gitkraft_core::features::staging::unstage_file(&repo, &file_path)
1004 .map_err(|e| format!("unstage: {e}"))?;
1005 Ok(format!("Unstaged: {file_path}"))
1006 });
1007 }
1008
1009 pub fn stage_all(&mut self) {
1010 bg_op!(self, "Staging all…", staging, |repo_path| {
1011 let repo = open_repo_str(&repo_path)?;
1012 gitkraft_core::features::staging::stage_all(&repo)
1013 .map_err(|e| format!("stage all: {e}"))?;
1014 Ok("Staged all files".into())
1015 });
1016 }
1017
1018 pub fn unstage_all(&mut self) {
1019 bg_op!(self, "Unstaging all…", staging, |repo_path| {
1020 let repo = open_repo_str(&repo_path)?;
1021 gitkraft_core::features::staging::unstage_all(&repo)
1022 .map_err(|e| format!("unstage all: {e}"))?;
1023 Ok("Unstaged all files".into())
1024 });
1025 }
1026
1027 pub fn discard_selected(&mut self) {
1028 let idx = match self.tab().unstaged_list_state.selected() {
1029 Some(i) => i,
1030 None => {
1031 self.tab_mut().status_message = Some("No unstaged file selected".into());
1032 return;
1033 }
1034 };
1035 let file_path = self.unstaged_file_path(idx);
1036 self.tab_mut().confirm_discard = false;
1037 bg_op!(self, "Discarding…", staging, |repo_path| {
1038 let repo = open_repo_str(&repo_path)?;
1039 gitkraft_core::features::staging::discard_file_changes(&repo, &file_path)
1040 .map_err(|e| format!("discard: {e}"))?;
1041 Ok(format!("Discarded changes: {file_path}"))
1042 });
1043 }
1044
1045 pub fn stage_files(&mut self, paths: Vec<String>) {
1047 let count = paths.len();
1048 bg_op!(
1049 self,
1050 format!("Staging {count} file(s)…"),
1051 staging,
1052 |repo_path| {
1053 let repo = open_repo_str(&repo_path)?;
1054 for fp in &paths {
1055 gitkraft_core::features::staging::stage_file(&repo, fp)
1056 .map_err(|e| e.to_string())?;
1057 }
1058 Ok(format!("{count} file(s) staged"))
1059 }
1060 );
1061 }
1062
1063 pub fn unstage_files(&mut self, paths: Vec<String>) {
1065 let count = paths.len();
1066 bg_op!(
1067 self,
1068 format!("Unstaging {count} file(s)…"),
1069 staging,
1070 |repo_path| {
1071 let repo = open_repo_str(&repo_path)?;
1072 for fp in &paths {
1073 gitkraft_core::features::staging::unstage_file(&repo, fp)
1074 .map_err(|e| e.to_string())?;
1075 }
1076 Ok(format!("{count} file(s) unstaged"))
1077 }
1078 );
1079 }
1080
1081 pub fn discard_files(&mut self, paths: Vec<String>) {
1083 let count = paths.len();
1084 bg_op!(
1085 self,
1086 format!("Discarding {count} file(s)…"),
1087 staging,
1088 |repo_path| {
1089 let repo = open_repo_str(&repo_path)?;
1090 for fp in &paths {
1091 gitkraft_core::features::staging::discard_file_changes(&repo, fp)
1092 .map_err(|e| e.to_string())?;
1093 }
1094 Ok(format!("{count} file(s) discarded"))
1095 }
1096 );
1097 }
1098
1099 pub fn create_commit(&mut self) {
1102 let msg = self.input_buffer.trim().to_string();
1103 if msg.is_empty() {
1104 self.tab_mut().error_message = Some("Commit message cannot be empty".into());
1105 return;
1106 }
1107 self.input_buffer.clear();
1108 bg_op!(self, "Committing…", refresh, |repo_path| {
1109 let repo = open_repo_str(&repo_path)?;
1110 let info = gitkraft_core::features::commits::create_commit(&repo, &msg)
1111 .map_err(|e| format!("commit: {e}"))?;
1112 Ok(format!("Committed: {} {}", info.short_oid, info.summary))
1113 });
1114 }
1115
1116 pub fn checkout_selected_branch(&mut self) {
1119 let idx = match self.tab().branch_list_state.selected() {
1120 Some(i) => i,
1121 None => return,
1122 };
1123 if idx >= self.tab().branches.len() {
1124 return;
1125 }
1126 let name = self.tab().branches[idx].name.clone();
1127 if self.tab().branches[idx].is_head {
1128 self.tab_mut().status_message = Some(format!("Already on '{name}'"));
1129 return;
1130 }
1131 bg_op!(self, "Checking out…", refresh, |repo_path| {
1132 let repo = open_repo_str(&repo_path)?;
1133 gitkraft_core::features::branches::checkout_branch(&repo, &name)
1134 .map_err(|e| format!("checkout: {e}"))?;
1135 Ok(format!("Checked out: {name}"))
1136 });
1137 }
1138
1139 pub fn create_branch(&mut self) {
1140 let name = self.input_buffer.trim().to_string();
1141 if name.is_empty() {
1142 self.tab_mut().error_message = Some("Branch name cannot be empty".into());
1143 return;
1144 }
1145 self.input_buffer.clear();
1146 bg_op!(self, "Creating branch…", refresh, |repo_path| {
1147 let repo = open_repo_str(&repo_path)?;
1148 gitkraft_core::features::branches::create_branch(&repo, &name)
1149 .map_err(|e| format!("create branch: {e}"))?;
1150 Ok(format!("Created branch: {name}"))
1151 });
1152 }
1153
1154 pub fn delete_selected_branch(&mut self) {
1155 let idx = match self.tab().branch_list_state.selected() {
1156 Some(i) => i,
1157 None => return,
1158 };
1159 if idx >= self.tab().branches.len() {
1160 return;
1161 }
1162 if self.tab().branches[idx].is_head {
1163 self.tab_mut().error_message = Some("Cannot delete the current branch".into());
1164 return;
1165 }
1166 let name = self.tab().branches[idx].name.clone();
1167 bg_op!(self, "Deleting branch…", refresh, |repo_path| {
1168 let repo = open_repo_str(&repo_path)?;
1169 gitkraft_core::features::branches::delete_branch(&repo, &name)
1170 .map_err(|e| format!("delete branch: {e}"))?;
1171 Ok(format!("Deleted branch: {name}"))
1172 });
1173 }
1174
1175 pub fn stash_save(&mut self) {
1178 let msg = if self.tab().stash_message_buffer.trim().is_empty() {
1179 None
1180 } else {
1181 Some(self.tab().stash_message_buffer.trim().to_string())
1182 };
1183 self.tab_mut().stash_message_buffer.clear();
1184 bg_op!(self, "Stashing…", refresh, |repo_path| {
1185 let mut repo = open_repo_str(&repo_path)?;
1186 let entry = gitkraft_core::features::stash::stash_save(&mut repo, msg.as_deref())
1187 .map_err(|e| format!("stash save: {e}"))?;
1188 Ok(format!("Stashed: {}", entry.message))
1189 });
1190 }
1191
1192 pub fn stash_pop_selected(&mut self) {
1193 let idx = self.tab().stash_list_state.selected().unwrap_or(0);
1194 if idx >= self.tab().stashes.len() {
1195 self.tab_mut().error_message = Some("No stash selected".into());
1196 return;
1197 }
1198 bg_op!(self, "Popping stash…", refresh, |repo_path| {
1199 let mut repo = open_repo_str(&repo_path)?;
1200 gitkraft_core::features::stash::stash_pop(&mut repo, idx)
1201 .map_err(|e| format!("stash pop: {e}"))?;
1202 Ok(format!("Stash @{{{idx}}} popped"))
1203 });
1204 }
1205
1206 pub fn stash_drop_selected(&mut self) {
1207 let idx = self.tab().stash_list_state.selected().unwrap_or(0);
1208 if idx >= self.tab().stashes.len() {
1209 self.tab_mut().error_message = Some("No stash to drop".into());
1210 return;
1211 }
1212 bg_op!(self, "Dropping stash…", refresh, |repo_path| {
1213 let mut repo = open_repo_str(&repo_path)?;
1214 gitkraft_core::features::stash::stash_drop(&mut repo, idx)
1215 .map_err(|e| format!("stash drop: {e}"))?;
1216 Ok(format!("Stash @{{{idx}}} dropped"))
1217 });
1218 }
1219
1220 pub fn load_commit_diff(&mut self) {
1224 let idx = match self.tab().commit_list_state.selected() {
1225 Some(i) => i,
1226 None => return,
1227 };
1228 if idx >= self.tab().commits.len() {
1229 return;
1230 }
1231 let oid = self.tab().commits[idx].oid.clone();
1232 self.tab_mut().selected_commit_oid = Some(oid.clone());
1233 bg_task!(
1234 self,
1235 "Loading files…",
1236 BackgroundResult::CommitFileListLoaded,
1237 |repo_path| {
1238 let repo = open_repo_str(&repo_path)?;
1239 gitkraft_core::features::diff::get_commit_file_list(&repo, &oid)
1240 .map_err(|e| e.to_string())
1241 }
1242 );
1243 }
1244
1245 pub fn load_single_file_diff(&mut self, file_index: usize, file_path: String) {
1247 let oid = match self.tab().selected_commit_oid.clone() {
1248 Some(o) => o,
1249 None => return,
1250 };
1251 bg_task!(
1252 self,
1253 "Loading diff…",
1254 BackgroundResult::SingleFileDiffLoaded,
1255 |repo_path| {
1256 let repo = open_repo_str(&repo_path)?;
1257 let diff =
1258 gitkraft_core::features::diff::get_single_file_diff(&repo, &oid, &file_path)
1259 .map_err(|e| e.to_string())?;
1260 Ok((file_index, diff))
1261 }
1262 );
1263 }
1264
1265 pub fn load_diff_for_file_index(&mut self, file_index: usize) {
1267 if file_index >= self.tab().commit_files.len() {
1268 return;
1269 }
1270 if self.tab().commit_diffs.contains_key(&file_index) {
1271 return;
1272 }
1273 let file_path = self.tab().commit_files[file_index]
1274 .display_path()
1275 .to_string();
1276 self.load_single_file_diff(file_index, file_path);
1277 }
1278
1279 pub fn next_diff_file(&mut self) {
1281 if self.tab().commit_files.is_empty() {
1282 return;
1283 }
1284 let new_index = (self.tab().commit_diff_file_index + 1) % self.tab().commit_files.len();
1285 self.tab_mut().anchor_file_index = Some(new_index);
1286 self.tab_mut().commit_diff_file_index = new_index;
1287 self.tab_mut().selected_file_indices.clear();
1288 self.tab_mut().selected_file_indices.insert(new_index);
1289 self.tab_mut().diff_scroll = 0;
1290 self.tab_mut().status_message = Some(format!(
1291 "File {}/{}",
1292 new_index + 1,
1293 self.tab().commit_files.len()
1294 ));
1295 if let Some(cached) = self.tab().commit_diffs.get(&new_index).cloned() {
1296 self.tab_mut().selected_diff = Some(cached);
1297 } else {
1298 let file_path = self.tab().commit_files[new_index]
1299 .display_path()
1300 .to_string();
1301 self.load_single_file_diff(new_index, file_path);
1302 }
1303 }
1304
1305 pub fn prev_diff_file(&mut self) {
1307 if self.tab().commit_files.is_empty() {
1308 return;
1309 }
1310 let new_index = if self.tab().commit_diff_file_index == 0 {
1311 self.tab().commit_files.len() - 1
1312 } else {
1313 self.tab().commit_diff_file_index - 1
1314 };
1315 self.tab_mut().anchor_file_index = Some(new_index);
1316 self.tab_mut().commit_diff_file_index = new_index;
1317 self.tab_mut().selected_file_indices.clear();
1318 self.tab_mut().selected_file_indices.insert(new_index);
1319 self.tab_mut().diff_scroll = 0;
1320 self.tab_mut().status_message = Some(format!(
1321 "File {}/{}",
1322 new_index + 1,
1323 self.tab().commit_files.len()
1324 ));
1325 if let Some(cached) = self.tab().commit_diffs.get(&new_index).cloned() {
1326 self.tab_mut().selected_diff = Some(cached);
1327 } else {
1328 let file_path = self.tab().commit_files[new_index]
1329 .display_path()
1330 .to_string();
1331 self.load_single_file_diff(new_index, file_path);
1332 }
1333 }
1334
1335 pub fn search_commits(&mut self, query: String) {
1338 let repo_path = match self.tab().repo_path.clone() {
1339 Some(p) => p,
1340 None => return,
1341 };
1342 self.tab_mut().search_query = query.clone();
1343 if query.trim().len() < 2 {
1344 self.tab_mut().search_results.clear();
1345 return;
1346 }
1347 let tx = self.bg_tx.clone();
1348 std::thread::spawn(move || {
1349 let res = (|| {
1350 let repo = open_repo_str(&repo_path)?;
1351 gitkraft_core::features::log::search_commits(&repo, &query, 100)
1352 .map_err(|e| e.to_string())
1353 })();
1354 let _ = tx.send(BackgroundResult::SearchResults(res));
1355 });
1356 }
1357
1358 pub fn load_commit_diff_by_oid(&mut self) {
1360 let oid = match self.tab().selected_commit_oid.clone() {
1361 Some(o) => o,
1362 None => return,
1363 };
1364 bg_task!(
1365 self,
1366 "Loading files…",
1367 BackgroundResult::CommitFileListLoaded,
1368 |repo_path| {
1369 let repo = open_repo_str(&repo_path)?;
1370 gitkraft_core::features::diff::get_commit_file_list(&repo, &oid)
1371 .map_err(|e| e.to_string())
1372 }
1373 );
1374 }
1375
1376 pub fn load_commit_range_diff(&mut self) {
1378 let selected = self.tab().selected_commits.clone();
1379 if selected.len() < 2 {
1380 return;
1381 }
1382 let oldest_idx = *selected.last().unwrap();
1384 let newest_idx = selected[0];
1385
1386 let oldest_oid = match self.tab().commits.get(oldest_idx).map(|c| c.oid.clone()) {
1387 Some(o) => o,
1388 None => return,
1389 };
1390 let newest_oid = match self.tab().commits.get(newest_idx).map(|c| c.oid.clone()) {
1391 Some(o) => o,
1392 None => return,
1393 };
1394
1395 bg_task!(
1396 self,
1397 "Loading range diff…",
1398 BackgroundResult::CommitRangeDiffLoaded,
1399 |repo_path| {
1400 let repo = open_repo_str(&repo_path)?;
1401 gitkraft_core::features::diff::get_commit_range_diff(
1402 &repo,
1403 &oldest_oid,
1404 &newest_oid,
1405 )
1406 .map_err(|e| e.to_string())
1407 }
1408 );
1409 }
1410
1411 pub fn close_repo(&mut self) {
1412 self.tabs[self.active_tab_index] = RepoTab::new();
1413 self.input_buffer.clear();
1414 self.show_theme_panel = false;
1415 self.show_options_panel = false;
1416 self.screen = AppScreen::Welcome;
1417 if let Ok(settings) = gitkraft_core::features::persistence::load_tui_settings() {
1419 self.recent_repos = settings.recent_repos;
1420 }
1421 self.save_session();
1422 }
1423
1424 pub fn refresh_browser(&mut self) {
1426 let mut entries = Vec::new();
1427 if let Ok(read_dir) = std::fs::read_dir(&self.browser_dir) {
1428 for entry in read_dir.flatten() {
1429 let path = entry.path();
1430 if path.is_dir() {
1432 entries.push(path);
1433 }
1434 }
1435 }
1436 entries.sort_by(|a, b| {
1437 let a_name = a
1438 .file_name()
1439 .unwrap_or_default()
1440 .to_string_lossy()
1441 .to_lowercase();
1442 let b_name = b
1443 .file_name()
1444 .unwrap_or_default()
1445 .to_string_lossy()
1446 .to_lowercase();
1447 let a_dot = a_name.starts_with('.');
1449 let b_dot = b_name.starts_with('.');
1450 a_dot.cmp(&b_dot).then(a_name.cmp(&b_name))
1451 });
1452 self.browser_entries = entries;
1453 self.browser_list_state = ListState::default();
1454 if !self.browser_entries.is_empty() {
1455 self.browser_list_state.select(Some(0));
1456 }
1457 }
1458
1459 pub fn open_browser(&mut self, start: PathBuf) {
1461 self.browser_return_screen = self.screen.clone();
1462 self.browser_dir = start;
1463 self.refresh_browser();
1464 self.screen = AppScreen::DirBrowser;
1465 }
1466 pub fn open_settings_in_editor(&mut self) {
1469 let path = match gitkraft_core::features::persistence::ops::tui_settings_json_path() {
1470 Ok(p) => p,
1471 Err(e) => {
1472 self.tab_mut().error_message = Some(format!("Cannot determine settings path: {e}"));
1473 return;
1474 }
1475 };
1476
1477 if !path.exists() {
1479 let snap =
1480 gitkraft_core::features::persistence::load_tui_settings().unwrap_or_default();
1481 let _ = gitkraft_core::features::persistence::save_tui_settings(&snap);
1482 }
1483
1484 let path_str = path.display().to_string();
1485
1486 if self.editor.is_terminal_editor() {
1487 self.tab_mut().status_message =
1491 Some(format!("Opening settings in {} — {path_str}", self.editor));
1492 self.pending_editor_open = Some(vec![path]);
1493 } else if !matches!(self.editor, gitkraft_core::Editor::None) {
1494 match self.editor.open_file(&path) {
1498 Ok(()) => {
1499 self.tab_mut().status_message =
1500 Some(format!("Settings opened in {} — {path_str}", self.editor));
1501 }
1502 Err(e) => {
1503 self.tab_mut().error_message =
1504 Some(format!("Could not open settings ({e}) — path: {path_str}"));
1505 }
1506 }
1507 } else {
1508 self.tab_mut().status_message = Some(format!(
1511 "Settings: {path_str} \
1512 (no editor configured — press E to choose one, or set editor in GUI)"
1513 ));
1514 }
1515 }
1516
1517 pub fn open_selected_in_editor(&mut self) {
1520 if matches!(self.editor, gitkraft_core::Editor::None) {
1521 self.tab_mut().status_message =
1522 Some("No editor configured — press E to choose one".into());
1523 return;
1524 }
1525 let file_path = match self.tab().staging_focus {
1526 StagingFocus::Unstaged => self
1527 .tab()
1528 .unstaged_list_state
1529 .selected()
1530 .and_then(|idx| self.tab().unstaged_changes.get(idx))
1531 .map(|d| d.display_path().to_string()),
1532 StagingFocus::Staged => self
1533 .tab()
1534 .staged_list_state
1535 .selected()
1536 .and_then(|idx| self.tab().staged_changes.get(idx))
1537 .map(|d| d.display_path().to_string()),
1538 };
1539 if let (Some(fp), Some(repo_path)) = (file_path, self.tab().repo_path.as_ref()) {
1540 let full_path = repo_path.join(&fp);
1541 if self.editor.is_terminal_editor() {
1542 self.tab_mut().status_message = Some(format!(
1545 "Opening {} in {} — suspending TUI",
1546 fp, self.editor
1547 ));
1548 self.pending_editor_open = Some(vec![full_path]);
1549 } else {
1550 match self.editor.open_file_or_default(&full_path) {
1551 Ok(method) => {
1552 self.tab_mut().status_message =
1553 Some(format!("Opened {} in {}", fp, method));
1554 }
1555 Err(e) => {
1556 self.tab_mut().error_message = Some(format!("Failed to open editor: {e}"));
1557 }
1558 }
1559 }
1560 }
1561 }
1562
1563 pub fn open_commit_files_in_editor(&mut self) {
1568 let repo_path = match self.tab().repo_path.clone() {
1569 Some(p) => p,
1570 None => {
1571 self.tab_mut().status_message = Some("No repository open".into());
1572 return;
1573 }
1574 };
1575
1576 let indices: Vec<usize> = if self.tab().selected_file_indices.len() > 1 {
1578 let mut v: Vec<usize> = self.tab().selected_file_indices.iter().copied().collect();
1579 v.sort_unstable();
1580 v
1581 } else {
1582 vec![self.tab().commit_diff_file_index]
1583 };
1584
1585 let paths: Vec<std::path::PathBuf> = indices
1586 .iter()
1587 .filter_map(|&i| {
1588 self.tab()
1589 .commit_files
1590 .get(i)
1591 .map(|f| repo_path.join(f.display_path()))
1592 })
1593 .collect();
1594
1595 if paths.is_empty() {
1596 return;
1597 }
1598
1599 let path_strs: Vec<String> = paths.iter().map(|p| p.display().to_string()).collect();
1600 let summary = if path_strs.len() == 1 {
1601 path_strs[0].clone()
1602 } else {
1603 format!("{} files", path_strs.len())
1604 };
1605
1606 if self.editor.is_terminal_editor() {
1607 self.tab_mut().status_message = Some(format!(
1608 "Opening {} in {} — suspending TUI",
1609 summary, self.editor
1610 ));
1611 self.pending_editor_open = Some(paths);
1612 } else if !matches!(self.editor, gitkraft_core::Editor::None) {
1613 let mut last_error: Option<String> = None;
1615 for path in &paths {
1616 if let Err(e) = self.editor.open_file(path) {
1617 last_error = Some(format!("{e}"));
1618 }
1619 }
1620 if let Some(e) = last_error {
1621 self.tab_mut().error_message = Some(format!("Failed to open in editor: {e}"));
1622 } else {
1623 self.tab_mut().status_message =
1624 Some(format!("Opened {} in {}", summary, self.editor));
1625 }
1626 } else {
1627 self.tab_mut().status_message = Some(format!(
1628 "Files: {} (no editor configured — press E to choose one)",
1629 path_strs.join(", ")
1630 ));
1631 }
1632 }
1633
1634 pub fn load_staging_diff(&mut self) {
1635 match self.tab().staging_focus {
1636 StagingFocus::Unstaged => {
1637 if let Some(idx) = self.tab().unstaged_list_state.selected() {
1638 if idx < self.tab().unstaged_changes.len() {
1639 let diff = self.tab().unstaged_changes[idx].clone();
1640 let tab = self.tab_mut();
1641 tab.selected_diff = Some(diff);
1642 tab.diff_scroll = 0;
1643 }
1644 }
1645 }
1646 StagingFocus::Staged => {
1647 if let Some(idx) = self.tab().staged_list_state.selected() {
1648 if idx < self.tab().staged_changes.len() {
1649 let diff = self.tab().staged_changes[idx].clone();
1650 let tab = self.tab_mut();
1651 tab.selected_diff = Some(diff);
1652 tab.diff_scroll = 0;
1653 }
1654 }
1655 }
1656 }
1657 }
1658
1659 pub fn fetch_remote(&mut self) {
1662 let repo_path = match self.tab().repo_path.clone() {
1663 Some(p) => p,
1664 None => return,
1665 };
1666 self.tab_mut().is_loading = true;
1667 self.tab_mut().status_message = Some("Fetching…".into());
1668 let tx = self.bg_tx.clone();
1669 std::thread::spawn(move || {
1670 let res = (|| {
1671 let repo = open_repo_str(&repo_path)?;
1672 gitkraft_core::features::remotes::fetch_remote(&repo, "origin")
1673 .map_err(|e| e.to_string())
1674 })();
1675 let _ = tx.send(BackgroundResult::FetchDone(res));
1676 });
1677 }
1678
1679 pub fn pull_rebase(&mut self) {
1680 let repo_path = match self.tab().repo_path.clone() {
1681 Some(p) => p,
1682 None => return,
1683 };
1684 self.tab_mut().is_loading = true;
1685 self.tab_mut().status_message = Some("Pulling (rebase)…".into());
1686 let tx = self.bg_tx.clone();
1687 std::thread::spawn(move || {
1688 let workdir = std::path::Path::new(&repo_path);
1689 let res = gitkraft_core::features::branches::pull_rebase(workdir, "origin");
1690 let _ = tx.send(BackgroundResult::OperationDone {
1691 ok_message: res
1692 .as_ref()
1693 .ok()
1694 .map(|_| "Pulled (rebase) from origin".into()),
1695 err_message: res.err().map(|e| format!("pull: {e}")),
1696 needs_refresh: true,
1697 needs_staging_refresh: false,
1698 });
1699 });
1700 }
1701
1702 pub fn push_branch(&mut self) {
1703 let repo_path = match self.tab().repo_path.clone() {
1704 Some(p) => p,
1705 None => return,
1706 };
1707 let branch = match self
1708 .tab()
1709 .repo_info
1710 .as_ref()
1711 .and_then(|i| i.head_branch.clone())
1712 {
1713 Some(b) => b,
1714 None => {
1715 self.tab_mut().error_message = Some("No branch checked out".into());
1716 return;
1717 }
1718 };
1719 self.tab_mut().is_loading = true;
1720 self.tab_mut().status_message = Some(format!("Pushing {branch}…"));
1721 let tx = self.bg_tx.clone();
1722 std::thread::spawn(move || {
1723 let workdir = std::path::Path::new(&repo_path);
1724 let res = gitkraft_core::features::branches::push_branch(workdir, &branch, "origin");
1725 let _ = tx.send(BackgroundResult::OperationDone {
1726 ok_message: res
1727 .as_ref()
1728 .ok()
1729 .map(|_| format!("Pushed {branch} to origin")),
1730 err_message: res.err().map(|e| format!("push: {e}")),
1731 needs_refresh: true,
1732 needs_staging_refresh: false,
1733 });
1734 });
1735 }
1736
1737 pub fn force_push_branch(&mut self) {
1738 let repo_path = match self.tab().repo_path.clone() {
1739 Some(p) => p,
1740 None => return,
1741 };
1742 let branch = match self
1743 .tab()
1744 .repo_info
1745 .as_ref()
1746 .and_then(|i| i.head_branch.clone())
1747 {
1748 Some(b) => b,
1749 None => {
1750 self.tab_mut().error_message = Some("No branch checked out".into());
1751 return;
1752 }
1753 };
1754 self.tab_mut().is_loading = true;
1755 self.tab_mut().status_message = Some(format!("Force pushing {branch}…"));
1756 let tx = self.bg_tx.clone();
1757 std::thread::spawn(move || {
1758 let workdir = std::path::Path::new(&repo_path);
1759 let res =
1760 gitkraft_core::features::branches::force_push_branch(workdir, &branch, "origin");
1761 let _ = tx.send(BackgroundResult::OperationDone {
1762 ok_message: res
1763 .as_ref()
1764 .ok()
1765 .map(|_| format!("Force pushed {branch} to origin")),
1766 err_message: res.err().map(|e| format!("force push: {e}")),
1767 needs_refresh: true,
1768 needs_staging_refresh: false,
1769 });
1770 });
1771 }
1772
1773 pub fn merge_selected_branch(&mut self) {
1774 let repo_path = match self.tab().repo_path.clone() {
1775 Some(p) => p,
1776 None => return,
1777 };
1778 let branch_name = match self.tab().branch_list_state.selected() {
1779 Some(idx) => match self.tab().branches.get(idx) {
1780 Some(b) => b.name.clone(),
1781 None => return,
1782 },
1783 None => return,
1784 };
1785 self.tab_mut().is_loading = true;
1786 self.tab_mut().status_message = Some(format!("Merging {branch_name}…"));
1787 let tx = self.bg_tx.clone();
1788 std::thread::spawn(move || {
1789 let res = (|| {
1790 let repo = open_repo_str(&repo_path)?;
1791 gitkraft_core::features::branches::merge_branch(&repo, &branch_name)
1792 .map_err(|e| e.to_string())
1793 })();
1794 let _ = tx.send(BackgroundResult::OperationDone {
1795 ok_message: res.as_ref().ok().map(|_| format!("Merged {branch_name}")),
1796 err_message: res.err(),
1797 needs_refresh: true,
1798 needs_staging_refresh: false,
1799 });
1800 });
1801 }
1802
1803 pub fn rebase_onto_selected_branch(&mut self) {
1804 let repo_path = match self.tab().repo_path.clone() {
1805 Some(p) => p,
1806 None => return,
1807 };
1808 let branch_name = match self.tab().branch_list_state.selected() {
1809 Some(idx) => match self.tab().branches.get(idx) {
1810 Some(b) => b.name.clone(),
1811 None => return,
1812 },
1813 None => return,
1814 };
1815 self.tab_mut().is_loading = true;
1816 self.tab_mut().status_message = Some(format!("Rebasing onto {branch_name}…"));
1817 let tx = self.bg_tx.clone();
1818 std::thread::spawn(move || {
1819 let workdir = std::path::Path::new(&repo_path);
1820 let res = gitkraft_core::features::branches::rebase_onto(workdir, &branch_name);
1821 let _ = tx.send(BackgroundResult::OperationDone {
1822 ok_message: res
1823 .as_ref()
1824 .ok()
1825 .map(|_| format!("Rebased onto {branch_name}")),
1826 err_message: res.err().map(|e| format!("rebase: {e}")),
1827 needs_refresh: true,
1828 needs_staging_refresh: false,
1829 });
1830 });
1831 }
1832
1833 pub fn open_commit_action_popup(&mut self) {
1835 let idx = match self.tab().commit_list_state.selected() {
1836 Some(i) => i,
1837 None => return,
1838 };
1839 let oid = match self.tab().commits.get(idx).map(|c| c.oid.clone()) {
1840 Some(o) => o,
1841 None => return,
1842 };
1843 let items: Vec<gitkraft_core::CommitActionKind> = gitkraft_core::COMMIT_MENU_GROUPS
1845 .iter()
1846 .flat_map(|g| g.iter().copied())
1847 .collect();
1848 let tab = self.tab_mut();
1849 tab.pending_commit_action_oid = Some(oid);
1850 tab.commit_action_items = items;
1851 tab.commit_action_cursor = 0;
1852 }
1853
1854 pub fn execute_commit_action(&mut self, action: gitkraft_core::CommitAction) {
1856 let oid = match self.tab().pending_commit_action_oid.clone() {
1857 Some(o) => o,
1858 None => return,
1859 };
1860 let repo_path = match self.tab().repo_path.clone() {
1861 Some(p) => p,
1862 None => return,
1863 };
1864 let label = action.label().to_string();
1865 let short = oid[..oid.len().min(7)].to_string();
1866 self.tab_mut().is_loading = true;
1867 self.tab_mut().status_message = Some(format!("{label} {short}…"));
1868 self.tab_mut().pending_commit_action_oid = None;
1869 self.tab_mut().pending_action_kind = None;
1870 self.tab_mut().action_input1.clear();
1871 let tx = self.bg_tx.clone();
1872 std::thread::spawn(move || {
1873 let workdir = repo_path.as_path();
1874 let res = action.execute(workdir, &oid);
1875 let _ = tx.send(crate::app::BackgroundResult::OperationDone {
1876 ok_message: res.as_ref().ok().map(|_| format!("{label} complete")),
1877 err_message: res.err().map(|e| e.to_string()),
1878 needs_refresh: true,
1879 needs_staging_refresh: false,
1880 });
1881 });
1882 }
1883
1884 pub fn open_file_history(&mut self, file_path: String) {
1886 let repo_path = match self.tab().repo_path.clone() {
1887 Some(p) => p,
1888 None => return,
1889 };
1890 let tab = self.tab_mut();
1891 tab.blame_path = None; tab.file_history_path = Some(file_path.clone());
1893 tab.file_history_commits.clear();
1894 tab.file_history_cursor = 0;
1895 tab.status_message = Some(format!(
1896 "Loading history for {}…",
1897 file_path.rsplit('/').next().unwrap_or(&file_path)
1898 ));
1899 let tx = self.bg_tx.clone();
1900 std::thread::spawn(move || {
1901 let repo = match gitkraft_core::features::repo::open_repo(&repo_path) {
1902 Ok(r) => r,
1903 Err(e) => {
1904 let _ = tx.send(BackgroundResult::OperationDone {
1905 ok_message: None,
1906 err_message: Some(format!("file history: {e}")),
1907 needs_refresh: false,
1908 needs_staging_refresh: false,
1909 });
1910 return;
1911 }
1912 };
1913 match gitkraft_core::file_history(&repo, &file_path, 500) {
1914 Ok(commits) => {
1915 let _ = tx.send(BackgroundResult::FileHistoryLoaded {
1916 path: file_path,
1917 commits,
1918 });
1919 }
1920 Err(e) => {
1921 let _ = tx.send(BackgroundResult::OperationDone {
1922 ok_message: None,
1923 err_message: Some(format!("file history: {e}")),
1924 needs_refresh: false,
1925 needs_staging_refresh: false,
1926 });
1927 }
1928 }
1929 });
1930 }
1931
1932 pub fn open_file_blame(&mut self, file_path: String) {
1934 let repo_path = match self.tab().repo_path.clone() {
1935 Some(p) => p,
1936 None => return,
1937 };
1938 let tab = self.tab_mut();
1939 tab.file_history_path = None; tab.blame_path = Some(file_path.clone());
1941 tab.blame_lines.clear();
1942 tab.blame_scroll = 0;
1943 tab.status_message = Some(format!(
1944 "Loading blame for {}…",
1945 file_path.rsplit('/').next().unwrap_or(&file_path)
1946 ));
1947 let tx = self.bg_tx.clone();
1948 std::thread::spawn(move || {
1949 let repo = match gitkraft_core::features::repo::open_repo(&repo_path) {
1950 Ok(r) => r,
1951 Err(e) => {
1952 let _ = tx.send(BackgroundResult::OperationDone {
1953 ok_message: None,
1954 err_message: Some(format!("blame: {e}")),
1955 needs_refresh: false,
1956 needs_staging_refresh: false,
1957 });
1958 return;
1959 }
1960 };
1961 match gitkraft_core::blame_file(&repo, &file_path) {
1962 Ok(lines) => {
1963 let _ = tx.send(BackgroundResult::FileBlameLoaded {
1964 path: file_path,
1965 lines,
1966 });
1967 }
1968 Err(e) => {
1969 let _ = tx.send(BackgroundResult::OperationDone {
1970 ok_message: None,
1971 err_message: Some(format!("blame: {e}")),
1972 needs_refresh: false,
1973 needs_staging_refresh: false,
1974 });
1975 }
1976 }
1977 });
1978 }
1979
1980 pub fn prompt_delete_file(&mut self, file_path: String) {
1982 let file_name = file_path
1983 .rsplit('/')
1984 .next()
1985 .unwrap_or(&file_path)
1986 .to_string();
1987 self.tab_mut().confirm_delete_file = Some(file_path);
1988 self.tab_mut().status_message = Some(format!(
1989 "Delete '{file_name}'? Press 'd' again to confirm, any other key to cancel"
1990 ));
1991 }
1992
1993 pub fn confirm_delete_file(&mut self) {
1995 let path = match self.tab().confirm_delete_file.clone() {
1996 Some(p) => p,
1997 None => return,
1998 };
1999 let repo_path = match self.tab().repo_path.clone() {
2000 Some(p) => p,
2001 None => return,
2002 };
2003 self.tab_mut().confirm_delete_file = None;
2004 self.tab_mut().is_loading = true;
2005 let file_name = path.rsplit('/').next().unwrap_or(&path).to_string();
2006 self.tab_mut().status_message = Some(format!("Deleting '{file_name}'…"));
2007 let tx = self.bg_tx.clone();
2008 std::thread::spawn(move || {
2009 let res = gitkraft_core::delete_file(repo_path.as_path(), &path);
2010 let _ = tx.send(BackgroundResult::OperationDone {
2011 ok_message: res.as_ref().ok().map(|_| format!("Deleted '{file_name}'")),
2012 err_message: res.err().map(|e| e.to_string()),
2013 needs_refresh: true,
2014 needs_staging_refresh: false,
2015 });
2016 });
2017 }
2018
2019 pub fn revert_selected_commit(&mut self) {
2020 let repo_path = match self.tab().repo_path.clone() {
2021 Some(p) => p,
2022 None => return,
2023 };
2024 let oid = match self.tab().commit_list_state.selected() {
2025 Some(idx) => match self.tab().commits.get(idx) {
2026 Some(c) => c.oid.clone(),
2027 None => return,
2028 },
2029 None => return,
2030 };
2031 self.tab_mut().is_loading = true;
2032 self.tab_mut().status_message = Some("Reverting commit…".into());
2033 let tx = self.bg_tx.clone();
2034 std::thread::spawn(move || {
2035 let workdir = std::path::Path::new(&repo_path);
2036 let res = gitkraft_core::features::repo::revert_commit(workdir, &oid);
2037 let _ = tx.send(BackgroundResult::OperationDone {
2038 ok_message: res.as_ref().ok().map(|_| format!("Reverted {}", &oid[..7])),
2039 err_message: res.err().map(|e| format!("revert: {e}")),
2040 needs_refresh: true,
2041 needs_staging_refresh: false,
2042 });
2043 });
2044 }
2045
2046 pub fn cherry_pick_selected(&mut self) {
2052 let repo_path = match self.tab().repo_path.clone() {
2053 Some(p) => p,
2054 None => return,
2055 };
2056
2057 let oids: Vec<String> = if self.tab().selected_commits.len() > 1 {
2059 let mut sorted = self.tab().selected_commits.clone();
2062 sorted.sort_unstable_by(|a, b| b.cmp(a)); sorted
2064 .iter()
2065 .filter_map(|&i| self.tab().commits.get(i).map(|c| c.oid.clone()))
2066 .collect()
2067 } else {
2068 match self.tab().commit_list_state.selected() {
2069 Some(i) => self
2070 .tab()
2071 .commits
2072 .get(i)
2073 .map(|c| vec![c.oid.clone()])
2074 .unwrap_or_default(),
2075 None => return,
2076 }
2077 };
2078
2079 if oids.is_empty() {
2080 return;
2081 }
2082
2083 let count = oids.len();
2084 let short = oids[0][..oids[0].len().min(7)].to_string();
2085 let label = if count == 1 {
2086 format!("Cherry-picking {short}…")
2087 } else {
2088 format!("Cherry-picking {count} commits…")
2089 };
2090
2091 let tab = self.tab_mut();
2092 tab.is_loading = true;
2093 tab.status_message = Some(label);
2094
2095 let tx = self.bg_tx.clone();
2096 std::thread::spawn(move || {
2097 let res: Result<String, String> = (|| {
2098 for oid in &oids {
2099 gitkraft_core::features::repo::cherry_pick_commit(&repo_path, oid)
2100 .map_err(|e| format!("cherry-pick {}: {e}", &oid[..oid.len().min(7)]))?;
2101 }
2102 Ok(format!("Cherry-picked {} commit(s)", oids.len()))
2103 })();
2104 let _ = tx.send(BackgroundResult::OperationDone {
2105 ok_message: res.as_ref().ok().cloned(),
2106 err_message: res.err(),
2107 needs_refresh: true,
2108 needs_staging_refresh: false,
2109 });
2110 });
2111 }
2112
2113 pub fn reset_to_selected_commit(&mut self, mode: &str) {
2114 let repo_path = match self.tab().repo_path.clone() {
2115 Some(p) => p,
2116 None => return,
2117 };
2118 let oid = match self.tab().commit_list_state.selected() {
2119 Some(idx) => match self.tab().commits.get(idx) {
2120 Some(c) => c.oid.clone(),
2121 None => return,
2122 },
2123 None => return,
2124 };
2125 let mode_owned = mode.to_string();
2126 self.tab_mut().is_loading = true;
2127 self.tab_mut().status_message = Some(format!("Resetting ({mode})…"));
2128 let tx = self.bg_tx.clone();
2129 std::thread::spawn(move || {
2130 let workdir = std::path::Path::new(&repo_path);
2131 let res = gitkraft_core::features::repo::reset_to_commit(workdir, &oid, &mode_owned);
2132 let _ = tx.send(BackgroundResult::OperationDone {
2133 ok_message: res
2134 .as_ref()
2135 .ok()
2136 .map(|_| format!("Reset ({mode_owned}) to {}", &oid[..7])),
2137 err_message: res.err().map(|e| format!("reset: {e}")),
2138 needs_refresh: true,
2139 needs_staging_refresh: false,
2140 });
2141 });
2142 }
2143
2144 fn unstaged_file_path(&self, idx: usize) -> String {
2147 if idx >= self.tab().unstaged_changes.len() {
2148 return String::new();
2149 }
2150 self.tab().unstaged_changes[idx].display_path().to_owned()
2151 }
2152
2153 fn staged_file_path(&self, idx: usize) -> String {
2154 if idx >= self.tab().staged_changes.len() {
2155 return String::new();
2156 }
2157 self.tab().staged_changes[idx].display_path().to_owned()
2158 }
2159}
2160
2161fn open_repo_str(path: &std::path::Path) -> Result<git2::Repository, String> {
2165 gitkraft_core::features::repo::open_repo(path).map_err(|e| e.to_string())
2166}
2167fn theme_name_to_index(name: &str) -> usize {
2169 gitkraft_core::theme_index_by_name(name)
2170}
2171
2172fn clamp_list_state(state: &mut ListState, len: usize) {
2174 if len == 0 {
2175 state.select(None);
2176 } else if state.selected().is_none() {
2177 state.select(Some(0));
2178 } else if let Some(i) = state.selected() {
2179 if i >= len {
2180 state.select(Some(len - 1));
2181 }
2182 }
2183}
2184
2185fn load_repo_blocking(path: &std::path::Path) -> Result<RepoPayload, String> {
2188 gitkraft_core::load_repo_snapshot(path).map_err(|e| e.to_string())
2189}
2190
2191#[cfg(test)]
2192mod tests {
2193 use super::*;
2194
2195 #[test]
2196 fn new_app_defaults() {
2197 let app = App::new();
2198 assert!(!app.should_quit);
2199 assert_eq!(app.screen, AppScreen::Welcome);
2200 assert_eq!(app.input_mode, InputMode::Normal);
2201 assert!(app.tab().commits.is_empty());
2202 assert!(app.tab().branches.is_empty());
2203 assert!(app.tab().repo_path.is_none());
2204 assert_eq!(app.tabs.len(), 1);
2205 assert_eq!(app.active_tab_index, 0);
2206 }
2207
2208 #[test]
2209 fn cycle_theme_next_wraps() {
2210 let mut app = App::new();
2211 app.current_theme_index = 0;
2212 app.cycle_theme_next();
2213 assert_eq!(app.current_theme_index, 1);
2214 for _ in 0..26 {
2216 app.cycle_theme_next();
2217 }
2218 assert_eq!(app.current_theme_index, 0); }
2220
2221 #[test]
2222 fn cycle_theme_prev_wraps() {
2223 let mut app = App::new();
2224 app.current_theme_index = 0;
2225 app.cycle_theme_prev();
2226 assert_eq!(app.current_theme_index, 26); }
2228
2229 #[test]
2230 fn theme_returns_struct() {
2231 let mut app = App::new();
2232 app.current_theme_index = 0;
2233 let theme = app.theme();
2234 assert_eq!(
2236 format!("{:?}", theme.border_active),
2237 format!("{:?}", ratatui::style::Color::Rgb(88, 166, 255))
2238 );
2239 }
2240
2241 #[test]
2242 fn theme_name_to_index_known() {
2243 assert_eq!(theme_name_to_index("Default"), 0);
2244 assert_eq!(theme_name_to_index("Dracula"), 8);
2245 assert_eq!(theme_name_to_index("Nord"), 9);
2246 }
2247
2248 #[test]
2249 fn theme_name_to_index_unknown_returns_zero() {
2250 assert_eq!(theme_name_to_index("NonExistentTheme"), 0);
2251 assert_eq!(theme_name_to_index(""), 0);
2252 }
2253
2254 #[test]
2255 fn tab_management_new_tab() {
2256 let mut app = App::new();
2257 assert_eq!(app.tabs.len(), 1);
2258 assert_eq!(app.active_tab_index, 0);
2259
2260 app.new_tab();
2261 assert_eq!(app.tabs.len(), 2);
2262 assert_eq!(app.active_tab_index, 1);
2263
2264 app.new_tab();
2265 assert_eq!(app.tabs.len(), 3);
2266 assert_eq!(app.active_tab_index, 2);
2267 }
2268
2269 #[test]
2270 fn tab_management_close_tab() {
2271 let mut app = App::new();
2272 app.new_tab();
2273 app.new_tab();
2274 assert_eq!(app.tabs.len(), 3);
2275 assert_eq!(app.active_tab_index, 2);
2276
2277 app.close_tab();
2278 assert_eq!(app.tabs.len(), 2);
2279 assert_eq!(app.active_tab_index, 1);
2280
2281 app.close_tab();
2282 assert_eq!(app.tabs.len(), 1);
2283 assert_eq!(app.active_tab_index, 0);
2284
2285 app.close_tab();
2287 assert_eq!(app.tabs.len(), 1);
2288 assert_eq!(app.active_tab_index, 0);
2289 }
2290
2291 #[test]
2292 fn tab_management_next_prev() {
2293 let mut app = App::new();
2294 app.new_tab();
2295 app.new_tab();
2296 app.next_tab();
2299 assert_eq!(app.active_tab_index, 0); app.next_tab();
2302 assert_eq!(app.active_tab_index, 1);
2303
2304 app.prev_tab();
2305 assert_eq!(app.active_tab_index, 0);
2306
2307 app.prev_tab();
2308 assert_eq!(app.active_tab_index, 2); }
2310
2311 #[test]
2312 fn repo_tab_display_name() {
2313 let tab = RepoTab::new();
2314 assert_eq!(tab.display_name(), "New Tab");
2315
2316 let mut tab2 = RepoTab::new();
2317 tab2.repo_path = Some(PathBuf::from("/home/user/projects/my-repo"));
2318 assert_eq!(tab2.display_name(), "my-repo");
2319 }
2320
2321 #[test]
2322 fn repo_tab_search_defaults() {
2323 let tab = RepoTab::new();
2324 assert!(!tab.search_active);
2325 assert!(tab.search_query.is_empty());
2326 assert!(tab.search_results.is_empty());
2327 }
2328
2329 #[test]
2330 fn repo_tab_new_has_empty_state() {
2331 let tab = RepoTab::new();
2332 assert!(tab.repo_path.is_none());
2333 assert!(tab.commits.is_empty());
2334 assert!(tab.branches.is_empty());
2335 assert!(tab.unstaged_changes.is_empty());
2336 assert!(tab.staged_changes.is_empty());
2337 assert!(tab.stashes.is_empty());
2338 assert!(tab.remotes.is_empty());
2339 assert!(tab.commit_files.is_empty());
2340 assert!(tab.selected_commit_oid.is_none());
2341 assert!(!tab.is_loading);
2342 assert!(!tab.confirm_discard);
2343 assert_eq!(tab.diff_scroll, 0);
2344 assert_eq!(tab.commit_diff_file_index, 0);
2345 }
2346
2347 #[test]
2348 fn new_tab_switches_to_welcome() {
2349 let mut app = App::new();
2350 app.screen = AppScreen::Main;
2351 app.new_tab();
2352 assert_eq!(app.screen, AppScreen::Welcome);
2353 assert_eq!(app.active_tab_index, 1);
2354 }
2355
2356 #[test]
2357 fn close_tab_last_tab_resets() {
2358 let mut app = App::new();
2359 app.tab_mut().search_active = true;
2361 app.tab_mut().search_query = "test".into();
2362
2363 app.close_tab();
2364
2365 assert_eq!(app.tabs.len(), 1);
2367 assert!(!app.tab().search_active);
2368 assert!(app.tab().search_query.is_empty());
2369 }
2370
2371 #[test]
2372 fn close_tab_middle_adjusts_index() {
2373 let mut app = App::new();
2374 app.new_tab();
2375 app.new_tab();
2376 app.active_tab_index = 1; app.close_tab();
2380
2381 assert_eq!(app.tabs.len(), 2);
2382 assert_eq!(app.active_tab_index, 1); }
2384
2385 #[test]
2386 fn next_tab_single_tab_no_change() {
2387 let mut app = App::new();
2388 app.next_tab();
2389 assert_eq!(app.active_tab_index, 0);
2390 }
2391
2392 #[test]
2393 fn prev_tab_single_tab_no_change() {
2394 let mut app = App::new();
2395 app.prev_tab();
2396 assert_eq!(app.active_tab_index, 0);
2397 }
2398
2399 #[test]
2400 fn open_browser_sets_dir_browser_screen() {
2401 let mut app = App::new();
2402 app.screen = AppScreen::Main;
2403 app.open_browser(PathBuf::from("/tmp"));
2404 assert_eq!(app.screen, AppScreen::DirBrowser);
2405 assert_eq!(app.browser_return_screen, AppScreen::Main);
2406 }
2407
2408 #[test]
2409 fn repo_tab_selected_defaults_empty() {
2410 let tab = RepoTab::new();
2411 assert!(tab.selected_unstaged.is_empty());
2412 assert!(tab.selected_staged.is_empty());
2413 }
2414
2415 #[test]
2416 fn repo_tab_selected_toggle() {
2417 let mut tab = RepoTab::new();
2418 tab.selected_unstaged.insert(0);
2419 tab.selected_unstaged.insert(2);
2420 assert_eq!(tab.selected_unstaged.len(), 2);
2421 assert!(tab.selected_unstaged.contains(&0));
2422 tab.selected_unstaged.remove(&0);
2423 assert_eq!(tab.selected_unstaged.len(), 1);
2424 assert!(!tab.selected_unstaged.contains(&0));
2425 }
2426
2427 #[test]
2428 fn auto_refresh_field_exists() {
2429 let app = App::new();
2430 assert!(app.last_auto_refresh.elapsed() < std::time::Duration::from_secs(1));
2431 }
2432
2433 #[test]
2434 fn editor_defaults_from_settings() {
2435 let app = App::new();
2436 let _ = app.editor.display_name();
2438 }
2439
2440 #[test]
2441 fn pull_rebase_sets_loading() {
2442 let mut app = App::new();
2443 app.tabs[0].repo_path = Some(PathBuf::from("/tmp/fake-repo"));
2444 app.pull_rebase();
2445 assert!(app.tab().is_loading);
2446 assert_eq!(
2447 app.tab().status_message.as_deref(),
2448 Some("Pulling (rebase)…")
2449 );
2450 }
2451
2452 #[test]
2453 fn repo_tab_diff_sub_pane_defaults_to_file_list() {
2454 let tab = RepoTab::new();
2455 assert_eq!(tab.diff_sub_pane, DiffSubPane::FileList);
2456 }
2457
2458 #[test]
2459 fn repo_tab_selected_file_indices_defaults_empty() {
2460 let tab = RepoTab::new();
2461 assert!(tab.selected_file_indices.is_empty());
2462 }
2463
2464 #[test]
2465 fn repo_tab_commit_diffs_defaults_empty_hashmap() {
2466 let tab = RepoTab::new();
2467 assert!(tab.commit_diffs.is_empty());
2468 }
2469
2470 #[test]
2471 fn next_tab_restores_main_screen_for_tab_with_repo() {
2472 let mut app = App::new();
2473 app.tabs[0].repo_path = Some(PathBuf::from("/tmp/repo-a"));
2475 app.new_tab();
2477 assert_eq!(app.active_tab_index, 1);
2478 app.next_tab();
2480 assert_eq!(app.active_tab_index, 0);
2481 assert_eq!(app.screen, AppScreen::Main);
2482 }
2483
2484 #[test]
2485 fn prev_tab_restores_welcome_for_tab_without_repo() {
2486 let mut app = App::new();
2487 app.tabs[0].repo_path = Some(PathBuf::from("/tmp/repo-a"));
2488 app.new_tab(); app.active_tab_index = 0;
2491 app.screen = AppScreen::Main;
2492 app.prev_tab();
2494 assert_eq!(app.active_tab_index, 1);
2495 assert_eq!(app.screen, AppScreen::Welcome);
2496 }
2497
2498 #[test]
2499 fn next_diff_file_clears_multi_selection() {
2500 let mut app = App::new();
2501 app.tab_mut().commit_files = vec![
2502 gitkraft_core::DiffFileEntry {
2503 old_file: String::new(),
2504 new_file: "a.rs".to_string(),
2505 status: gitkraft_core::FileStatus::Modified,
2506 },
2507 gitkraft_core::DiffFileEntry {
2508 old_file: String::new(),
2509 new_file: "b.rs".to_string(),
2510 status: gitkraft_core::FileStatus::Modified,
2511 },
2512 gitkraft_core::DiffFileEntry {
2513 old_file: String::new(),
2514 new_file: "c.rs".to_string(),
2515 status: gitkraft_core::FileStatus::Modified,
2516 },
2517 ];
2518 app.tab_mut().commit_diff_file_index = 0;
2519 app.tab_mut().selected_file_indices.insert(0);
2521 app.tab_mut().selected_file_indices.insert(1);
2522 app.next_diff_file();
2523 assert_eq!(app.tab().selected_file_indices.len(), 1);
2525 assert_eq!(app.tab().commit_diff_file_index, 1);
2526 assert!(app.tab().selected_file_indices.contains(&1));
2527 }
2528
2529 #[test]
2530 fn prev_diff_file_clears_multi_selection() {
2531 let mut app = App::new();
2532 app.tab_mut().commit_files = vec![
2533 gitkraft_core::DiffFileEntry {
2534 old_file: String::new(),
2535 new_file: "a.rs".to_string(),
2536 status: gitkraft_core::FileStatus::Modified,
2537 },
2538 gitkraft_core::DiffFileEntry {
2539 old_file: String::new(),
2540 new_file: "b.rs".to_string(),
2541 status: gitkraft_core::FileStatus::Modified,
2542 },
2543 ];
2544 app.tab_mut().commit_diff_file_index = 1;
2545 app.tab_mut().selected_file_indices.insert(0);
2546 app.tab_mut().selected_file_indices.insert(1);
2547 app.prev_diff_file();
2548 assert_eq!(app.tab().selected_file_indices.len(), 1);
2549 assert_eq!(app.tab().commit_diff_file_index, 0);
2550 assert!(app.tab().selected_file_indices.contains(&0));
2551 }
2552
2553 #[test]
2554 fn load_diff_for_file_index_out_of_bounds_is_noop() {
2555 let mut app = App::new();
2556 app.load_diff_for_file_index(0);
2558 assert!(app.tab().commit_diffs.is_empty());
2559 }
2560
2561 #[test]
2562 fn load_diff_for_file_index_skips_if_already_cached() {
2563 let mut app = App::new();
2564 app.tab_mut().commit_files = vec![gitkraft_core::DiffFileEntry {
2565 old_file: String::new(),
2566 new_file: "a.rs".to_string(),
2567 status: gitkraft_core::FileStatus::Modified,
2568 }];
2569 app.tab_mut().commit_diffs.insert(
2571 0,
2572 DiffInfo {
2573 old_file: String::new(),
2574 new_file: "a.rs".to_string(),
2575 status: gitkraft_core::FileStatus::Modified,
2576 hunks: Vec::new(),
2577 },
2578 );
2579 app.load_diff_for_file_index(0);
2582 assert!(!app.tab().is_loading);
2583 }
2584
2585 #[test]
2586 fn push_branch_requires_head_branch() {
2587 let mut app = App::new();
2588 app.tabs[0].repo_path = Some(PathBuf::from("/tmp/fake-repo"));
2589 app.push_branch();
2591 assert!(app.tab().error_message.is_some());
2592 }
2593
2594 #[test]
2595 fn force_push_requires_head_branch() {
2596 let mut app = App::new();
2597 app.tabs[0].repo_path = Some(PathBuf::from("/tmp/fake-repo"));
2598 app.force_push_branch();
2599 assert!(app.tab().error_message.is_some());
2600 }
2601
2602 #[test]
2603 fn merge_selected_branch_no_selection() {
2604 let mut app = App::new();
2605 app.tabs[0].repo_path = Some(PathBuf::from("/tmp/fake-repo"));
2606 app.merge_selected_branch();
2608 assert!(!app.tab().is_loading);
2609 }
2610
2611 #[test]
2612 fn rebase_onto_selected_no_selection() {
2613 let mut app = App::new();
2614 app.tabs[0].repo_path = Some(PathBuf::from("/tmp/fake-repo"));
2615 app.rebase_onto_selected_branch();
2616 assert!(!app.tab().is_loading);
2617 }
2618
2619 #[test]
2620 fn revert_selected_commit_no_selection() {
2621 let mut app = App::new();
2622 app.tabs[0].repo_path = Some(PathBuf::from("/tmp/fake-repo"));
2623 app.revert_selected_commit();
2624 assert!(!app.tab().is_loading);
2625 }
2626
2627 #[test]
2628 fn reset_to_selected_commit_no_selection() {
2629 let mut app = App::new();
2630 app.tabs[0].repo_path = Some(PathBuf::from("/tmp/fake-repo"));
2631 app.reset_to_selected_commit("soft");
2632 assert!(!app.tab().is_loading);
2633 }
2634
2635 #[test]
2636 fn open_repo_creates_new_tab_when_current_has_repo() {
2637 let mut app = App::new();
2638 app.tabs[0].repo_path = Some(PathBuf::from("/tmp/repo1"));
2639 app.screen = AppScreen::Main;
2640 let initial_tabs = app.tabs.len();
2642 if app.tab().repo_path.is_some() {
2643 app.new_tab();
2644 }
2645 assert_eq!(app.tabs.len(), initial_tabs + 1);
2646 }
2647
2648 #[test]
2651 fn commit_action_items_defaults_empty() {
2652 let tab = RepoTab::new();
2653 assert!(tab.commit_action_items.is_empty());
2654 assert_eq!(tab.commit_action_cursor, 0);
2655 assert!(tab.pending_commit_action_oid.is_none());
2656 assert!(tab.pending_action_kind.is_none());
2657 assert!(tab.action_input1.is_empty());
2658 }
2659
2660 #[test]
2661 fn open_commit_action_popup_no_selection_is_noop() {
2662 let mut app = App::new();
2663 app.open_commit_action_popup();
2665 assert!(app.tab().pending_commit_action_oid.is_none());
2666 assert!(app.tab().commit_action_items.is_empty());
2667 }
2668
2669 #[test]
2670 fn open_commit_action_popup_fills_items_from_menu_groups() {
2671 let mut app = App::new();
2672 app.tab_mut().commits = vec![gitkraft_core::CommitInfo {
2674 oid: "abc1234567890".to_string(),
2675 short_oid: "abc1234".to_string(),
2676 summary: "test commit".to_string(),
2677 message: "test commit".to_string(),
2678 author_name: "author".to_string(),
2679 author_email: "a@b.com".to_string(),
2680 time: Default::default(),
2681 parent_ids: vec![],
2682 }];
2683 app.tab_mut().commit_list_state.select(Some(0));
2684
2685 app.open_commit_action_popup();
2686
2687 let expected: Vec<gitkraft_core::CommitActionKind> = gitkraft_core::COMMIT_MENU_GROUPS
2689 .iter()
2690 .flat_map(|g| g.iter().copied())
2691 .collect();
2692 assert_eq!(app.tab().commit_action_items, expected);
2693 assert_eq!(app.tab().commit_action_items.len(), 10);
2694 }
2695
2696 #[test]
2697 fn open_commit_action_popup_sets_pending_oid() {
2698 let mut app = App::new();
2699 app.tab_mut().commits = vec![gitkraft_core::CommitInfo {
2700 oid: "deadbeef1234567".to_string(),
2701 short_oid: "deadbee".to_string(),
2702 summary: "s".to_string(),
2703 message: "s".to_string(),
2704 author_name: "a".to_string(),
2705 author_email: "a@b.com".to_string(),
2706 time: Default::default(),
2707 parent_ids: vec![],
2708 }];
2709 app.tab_mut().commit_list_state.select(Some(0));
2710
2711 app.open_commit_action_popup();
2712
2713 assert_eq!(
2714 app.tab().pending_commit_action_oid.as_deref(),
2715 Some("deadbeef1234567")
2716 );
2717 assert_eq!(app.tab().commit_action_cursor, 0);
2718 }
2719
2720 #[test]
2721 fn open_commit_action_popup_resets_cursor() {
2722 let mut app = App::new();
2723 app.tab_mut().commits = vec![gitkraft_core::CommitInfo {
2724 oid: "aaa".to_string(),
2725 short_oid: "aaa".to_string(),
2726 summary: "s".to_string(),
2727 message: "s".to_string(),
2728 author_name: "a".to_string(),
2729 author_email: "a@b.com".to_string(),
2730 time: Default::default(),
2731 parent_ids: vec![],
2732 }];
2733 app.tab_mut().commit_list_state.select(Some(0));
2734 app.tab_mut().commit_action_cursor = 5;
2736
2737 app.open_commit_action_popup();
2738
2739 assert_eq!(app.tab().commit_action_cursor, 0);
2740 }
2741
2742 #[test]
2743 fn execute_commit_action_no_pending_oid_is_noop() {
2744 let mut app = App::new();
2745 app.execute_commit_action(gitkraft_core::CommitAction::CherryPick);
2747 assert!(!app.tab().is_loading);
2748 }
2749
2750 #[test]
2751 fn execute_commit_action_no_repo_path_is_noop() {
2752 let mut app = App::new();
2753 app.tab_mut().pending_commit_action_oid = Some("abc123".to_string());
2754 app.execute_commit_action(gitkraft_core::CommitAction::CherryPick);
2756 assert!(!app.tab().is_loading);
2757 }
2758
2759 #[test]
2760 fn execute_commit_action_sets_loading_and_clears_state() {
2761 let mut app = App::new();
2762 app.tab_mut().pending_commit_action_oid = Some("abc123".to_string());
2763 app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2764 app.tab_mut().pending_action_kind = Some(gitkraft_core::CommitActionKind::CherryPick);
2765 app.tab_mut().action_input1 = "some-input".to_string();
2766
2767 app.execute_commit_action(gitkraft_core::CommitAction::CherryPick);
2768
2769 assert!(app.tab().is_loading);
2770 assert!(app.tab().pending_commit_action_oid.is_none());
2772 assert!(app.tab().pending_action_kind.is_none());
2773 assert!(app.tab().action_input1.is_empty());
2774 }
2775
2776 #[test]
2779 fn file_history_defaults_empty() {
2780 let tab = RepoTab::new();
2781 assert!(tab.file_history_path.is_none());
2782 assert!(tab.file_history_commits.is_empty());
2783 assert_eq!(tab.file_history_cursor, 0);
2784 }
2785
2786 #[test]
2787 fn blame_defaults_empty() {
2788 let tab = RepoTab::new();
2789 assert!(tab.blame_path.is_none());
2790 assert!(tab.blame_lines.is_empty());
2791 assert_eq!(tab.blame_scroll, 0);
2792 }
2793
2794 #[test]
2795 fn confirm_delete_defaults_none() {
2796 let tab = RepoTab::new();
2797 assert!(tab.confirm_delete_file.is_none());
2798 }
2799
2800 #[test]
2801 fn open_file_history_no_repo_is_noop() {
2802 let mut app = App::new();
2803 app.open_file_history("src/main.rs".to_string());
2805 assert!(app.tab().file_history_path.is_none());
2806 }
2807
2808 #[test]
2809 fn open_file_history_sets_path_and_clears_blame() {
2810 let mut app = App::new();
2811 app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2812 app.tab_mut().blame_path = Some("old.rs".to_string());
2813
2814 app.open_file_history("src/main.rs".to_string());
2815
2816 assert_eq!(app.tab().file_history_path.as_deref(), Some("src/main.rs"));
2817 assert!(app.tab().blame_path.is_none());
2819 }
2820
2821 #[test]
2822 fn open_file_blame_sets_path_and_clears_history() {
2823 let mut app = App::new();
2824 app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2825 app.tab_mut().file_history_path = Some("old.rs".to_string());
2826
2827 app.open_file_blame("src/lib.rs".to_string());
2828
2829 assert_eq!(app.tab().blame_path.as_deref(), Some("src/lib.rs"));
2830 assert!(app.tab().file_history_path.is_none());
2832 }
2833
2834 #[test]
2835 fn prompt_delete_file_sets_confirm_and_status() {
2836 let mut app = App::new();
2837 app.prompt_delete_file("src/old.rs".to_string());
2838 assert_eq!(app.tab().confirm_delete_file.as_deref(), Some("src/old.rs"));
2839 assert!(app.tab().status_message.is_some());
2840 }
2841
2842 #[test]
2843 fn confirm_delete_file_no_repo_is_noop() {
2844 let mut app = App::new();
2845 app.tab_mut().confirm_delete_file = Some("src/old.rs".to_string());
2846 app.confirm_delete_file();
2848 assert!(!app.tab().is_loading);
2849 }
2850
2851 #[test]
2854 fn open_commit_files_in_editor_shows_path_when_no_editor() {
2855 let mut app = App::new();
2856 app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/repo"));
2857 app.tab_mut().commit_files = vec![gitkraft_core::DiffFileEntry {
2858 old_file: String::new(),
2859 new_file: "src/main.rs".to_string(),
2860 status: gitkraft_core::FileStatus::Modified,
2861 }];
2862 app.tab_mut().commit_diff_file_index = 0;
2863 app.open_commit_files_in_editor();
2866
2867 assert!(app.tab().status_message.is_some());
2869 let msg = app.tab().status_message.as_deref().unwrap();
2870 assert!(msg.contains("no editor") || msg.contains("src/main.rs"));
2871 }
2872
2873 #[test]
2874 fn open_commit_files_in_editor_queues_multi_file_for_terminal_editor() {
2875 let mut app = App::new();
2876 app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/repo"));
2877 app.tab_mut().commit_files = vec![
2878 gitkraft_core::DiffFileEntry {
2879 old_file: String::new(),
2880 new_file: "a.rs".to_string(),
2881 status: gitkraft_core::FileStatus::Modified,
2882 },
2883 gitkraft_core::DiffFileEntry {
2884 old_file: String::new(),
2885 new_file: "b.rs".to_string(),
2886 status: gitkraft_core::FileStatus::Modified,
2887 },
2888 ];
2889 app.tab_mut().selected_file_indices.insert(0);
2890 app.tab_mut().selected_file_indices.insert(1);
2891 app.editor = gitkraft_core::Editor::Helix; app.open_commit_files_in_editor();
2894
2895 let queued = app.pending_editor_open.as_ref().unwrap();
2897 assert_eq!(queued.len(), 2);
2898 }
2899
2900 #[test]
2901 fn cherry_pick_selected_single_commit_sets_loading() {
2902 let mut app = App::new();
2903 app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2904 app.tab_mut().commits = vec![gitkraft_core::CommitInfo {
2905 oid: "abc1234567890".to_string(),
2906 short_oid: "abc1234".to_string(),
2907 summary: "test".into(),
2908 message: "test".into(),
2909 author_name: "A".into(),
2910 author_email: "a@a.com".into(),
2911 time: Default::default(),
2912 parent_ids: Vec::new(),
2913 }];
2914 app.tab_mut().commit_list_state.select(Some(0));
2915
2916 app.cherry_pick_selected();
2917
2918 assert!(app.tab().is_loading);
2919 }
2920
2921 #[test]
2922 fn cherry_pick_selected_no_repo_path_is_noop() {
2923 let mut app = App::new();
2924 app.tab_mut().commits = vec![gitkraft_core::CommitInfo {
2925 oid: "abc1234567890".to_string(),
2926 short_oid: "abc1234".to_string(),
2927 summary: "test".into(),
2928 message: "test".into(),
2929 author_name: "A".into(),
2930 author_email: "a@a.com".into(),
2931 time: Default::default(),
2932 parent_ids: Vec::new(),
2933 }];
2934 app.tab_mut().commit_list_state.select(Some(0));
2935
2936 app.cherry_pick_selected();
2937
2938 assert!(!app.tab().is_loading);
2939 }
2940
2941 #[test]
2942 fn cherry_pick_selected_no_cursor_is_noop() {
2943 let mut app = App::new();
2944 app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2945 app.tab_mut().commits = vec![gitkraft_core::CommitInfo {
2946 oid: "abc1234567890".to_string(),
2947 short_oid: "abc1234".to_string(),
2948 summary: "test".into(),
2949 message: "test".into(),
2950 author_name: "A".into(),
2951 author_email: "a@a.com".into(),
2952 time: Default::default(),
2953 parent_ids: Vec::new(),
2954 }];
2955 app.cherry_pick_selected();
2958
2959 assert!(
2960 !app.tab().is_loading,
2961 "no cursor → cherry_pick_selected must be a noop"
2962 );
2963 }
2964
2965 #[test]
2966 fn cherry_pick_selected_single_sets_status_message_with_short_oid() {
2967 let mut app = App::new();
2968 app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2969 app.tab_mut().commits = vec![gitkraft_core::CommitInfo {
2970 oid: "deadbeef12345".to_string(),
2971 short_oid: "deadbee".to_string(),
2972 summary: "fix: something".into(),
2973 message: "fix: something".into(),
2974 author_name: "A".into(),
2975 author_email: "a@a.com".into(),
2976 time: Default::default(),
2977 parent_ids: Vec::new(),
2978 }];
2979 app.tab_mut().commit_list_state.select(Some(0));
2980
2981 app.cherry_pick_selected();
2982
2983 let msg = app.tab().status_message.as_deref().unwrap_or("");
2984 assert!(
2985 msg.contains("deadbee"),
2986 "status message must contain the short OID; got: {msg}"
2987 );
2988 assert!(
2989 msg.to_lowercase().contains("cherry"),
2990 "status message must mention cherry-pick; got: {msg}"
2991 );
2992 }
2993
2994 #[test]
2995 fn cherry_pick_selected_multi_uses_selected_commits_and_sets_count_message() {
2996 let mut app = App::new();
2997 app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2998 app.tab_mut().commits = vec![
3000 gitkraft_core::CommitInfo {
3001 oid: "oid_newest".to_string(),
3002 short_oid: "newest".to_string(),
3003 summary: "newest".into(),
3004 message: "newest".into(),
3005 author_name: "A".into(),
3006 author_email: "a@a.com".into(),
3007 time: Default::default(),
3008 parent_ids: Vec::new(),
3009 },
3010 gitkraft_core::CommitInfo {
3011 oid: "oid_middle".to_string(),
3012 short_oid: "middle".to_string(),
3013 summary: "middle".into(),
3014 message: "middle".into(),
3015 author_name: "A".into(),
3016 author_email: "a@a.com".into(),
3017 time: Default::default(),
3018 parent_ids: Vec::new(),
3019 },
3020 gitkraft_core::CommitInfo {
3021 oid: "oid_oldest".to_string(),
3022 short_oid: "oldest".to_string(),
3023 summary: "oldest".into(),
3024 message: "oldest".into(),
3025 author_name: "A".into(),
3026 author_email: "a@a.com".into(),
3027 time: Default::default(),
3028 parent_ids: Vec::new(),
3029 },
3030 ];
3031 app.tab_mut().selected_commits = vec![0, 1, 2];
3033 app.tab_mut().commit_list_state.select(Some(0));
3034
3035 app.cherry_pick_selected();
3036
3037 assert!(
3038 app.tab().is_loading,
3039 "multi cherry-pick must set is_loading"
3040 );
3041 let msg = app.tab().status_message.as_deref().unwrap_or("");
3042 assert!(
3043 msg.contains("3"),
3044 "status message must mention commit count (3); got: {msg}"
3045 );
3046 }
3047
3048 #[test]
3049 fn cherry_pick_selected_multi_orders_oldest_first() {
3050 let mut app = App::new();
3054 app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
3055 app.tab_mut().commits = vec![
3056 gitkraft_core::CommitInfo {
3057 oid: "oid_0_newest".to_string(),
3058 short_oid: "oid0new".to_string(),
3059 summary: "newest".into(),
3060 message: "newest".into(),
3061 author_name: "A".into(),
3062 author_email: "a@a.com".into(),
3063 time: Default::default(),
3064 parent_ids: Vec::new(),
3065 },
3066 gitkraft_core::CommitInfo {
3067 oid: "oid_1_oldest".to_string(),
3068 short_oid: "oid1old".to_string(),
3069 summary: "oldest".into(),
3070 message: "oldest".into(),
3071 author_name: "A".into(),
3072 author_email: "a@a.com".into(),
3073 time: Default::default(),
3074 parent_ids: Vec::new(),
3075 },
3076 ];
3077 app.tab_mut().selected_commits = vec![0, 1];
3078
3079 app.cherry_pick_selected();
3080
3081 let msg = app.tab().status_message.as_deref().unwrap_or("");
3084 assert!(
3085 msg.contains("2"),
3086 "multi cherry-pick message should say 2 commits; got: {msg}"
3087 );
3088 }
3089
3090 #[test]
3091 fn open_commit_files_in_editor_uses_single_cursor_when_no_multi_selection() {
3092 let mut app = App::new();
3093 app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/repo"));
3094 app.tab_mut().commit_files = vec![
3095 gitkraft_core::DiffFileEntry {
3096 old_file: String::new(),
3097 new_file: "a.rs".to_string(),
3098 status: gitkraft_core::FileStatus::Modified,
3099 },
3100 gitkraft_core::DiffFileEntry {
3101 old_file: String::new(),
3102 new_file: "b.rs".to_string(),
3103 status: gitkraft_core::FileStatus::Modified,
3104 },
3105 ];
3106 app.tab_mut().commit_diff_file_index = 1; app.editor = gitkraft_core::Editor::Helix;
3108 app.open_commit_files_in_editor();
3111
3112 let queued = app.pending_editor_open.as_ref().unwrap();
3113 assert_eq!(queued.len(), 1);
3114 assert!(queued[0].ends_with("b.rs"));
3115 }
3116}