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
66macro_rules! selected_idx {
71 ($self:expr, $state:ident, $vec:ident, $err:expr) => {{
72 let idx = $self.tab().$state.selected().unwrap_or(0);
73 if idx >= $self.tab().$vec.len() {
74 $self.tab_mut().error_message = Some($err.into());
75 return;
76 }
77 idx
78 }};
79}
80
81macro_rules! require_selection {
88 ($self:expr, $state:ident, $msg:expr) => {{
89 match $self.tab().$state.selected() {
90 Some(i) => i,
91 None => {
92 $self.tab_mut().status_message = Some($msg.into());
93 return;
94 }
95 }
96 }};
97}
98
99pub type RepoPayload = gitkraft_core::RepoSnapshot;
104
105#[derive(Debug)]
107pub enum BackgroundResult {
108 RepoLoaded {
111 path: PathBuf,
112 result: Result<RepoPayload, String>,
113 },
114 FetchDone(Result<(), String>),
116 CommitDiffLoaded(Result<Vec<DiffInfo>, String>),
118 StagingRefreshed(Result<StagingPayload, String>),
120 OperationDone {
123 ok_message: Option<String>,
124 err_message: Option<String>,
125 needs_refresh: bool,
127 needs_staging_refresh: bool,
129 },
130 CommitFileListLoaded(Result<Vec<gitkraft_core::DiffFileEntry>, String>),
132 SingleFileDiffLoaded(Result<(usize, gitkraft_core::DiffInfo), String>),
134 SearchResults(Result<Vec<gitkraft_core::CommitInfo>, String>),
136 CommitRangeDiffLoaded(Result<Vec<gitkraft_core::DiffInfo>, String>),
138 FileHistoryLoaded {
140 path: String,
141 commits: Vec<gitkraft_core::CommitInfo>,
142 },
143 FileBlameLoaded {
145 path: String,
146 lines: Vec<gitkraft_core::BlameLine>,
147 },
148 GitStateChanged,
150}
151
152#[derive(Debug)]
154pub struct StagingPayload {
155 pub unstaged: Vec<DiffInfo>,
156 pub staged: Vec<DiffInfo>,
157}
158
159#[derive(Debug, Clone, PartialEq, Eq)]
162pub enum AppScreen {
163 Welcome,
164 DirBrowser,
165 Main,
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub enum ActivePane {
170 Branches,
171 CommitLog,
172 DiffView,
173 Staging,
174 Stash,
175}
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
178pub enum InputMode {
179 Normal,
180 Input,
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub enum InputPurpose {
185 None,
186 CommitMessage,
187 BranchName,
188 RepoPath,
189 SearchQuery,
190 StashMessage,
191 CommitActionInput1,
193 CommitActionInput2,
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199pub enum StagingFocus {
200 Unstaged,
201 Staged,
202}
203
204#[derive(Debug, Clone, PartialEq, Eq)]
206pub enum DiffSubPane {
207 FileList,
209 Content,
211}
212
213pub struct RepoTab {
217 pub repo_path: Option<PathBuf>,
218 pub repo_info: Option<RepoInfo>,
219
220 pub branches: Vec<BranchInfo>,
221 pub branch_list_state: ListState,
222
223 pub commits: Vec<CommitInfo>,
224 pub graph_rows: Vec<gitkraft_core::GraphRow>,
225 pub commit_list_state: ListState,
226
227 pub unstaged_changes: Vec<DiffInfo>,
228 pub staged_changes: Vec<DiffInfo>,
229 pub unstaged_list_state: ListState,
230 pub staged_list_state: ListState,
231 pub staging_focus: StagingFocus,
232 pub selected_diff: Option<DiffInfo>,
233 pub diff_scroll: u16,
234 pub diff_sub_pane: DiffSubPane,
236 pub anchor_file_index: Option<usize>,
238 pub selected_file_indices: std::collections::HashSet<usize>,
240 pub commit_diffs: std::collections::HashMap<usize, DiffInfo>,
242 pub commit_diff_file_index: usize,
244 pub commit_files: Vec<gitkraft_core::DiffFileEntry>,
246 pub selected_commit_oid: Option<String>,
248
249 pub stashes: Vec<StashEntry>,
250 pub stash_list_state: ListState,
251 pub remotes: Vec<RemoteInfo>,
252
253 pub search_query: String,
255 pub search_active: bool,
257 pub search_results: Vec<CommitInfo>,
259
260 pub stash_message_buffer: String,
262
263 pub status_message: Option<String>,
264 pub error_message: Option<String>,
265
266 pub is_loading: bool,
268
269 pub confirm_discard: bool,
272
273 pub selected_unstaged: std::collections::HashSet<usize>,
275 pub selected_staged: std::collections::HashSet<usize>,
277 pub anchor_unstaged: Option<usize>,
279 pub anchor_staged: Option<usize>,
281
282 pub anchor_commit_index: Option<usize>,
284 pub selected_commits: Vec<usize>,
286 pub commit_range_diffs: Vec<DiffInfo>,
288
289 pub commit_action_items: Vec<gitkraft_core::CommitActionKind>,
292 pub commit_action_cursor: usize,
294 pub pending_commit_action_oid: Option<String>,
296 pub pending_action_kind: Option<gitkraft_core::CommitActionKind>,
298 pub action_input1: String,
300
301 pub file_history_path: Option<String>,
303 pub file_history_commits: Vec<gitkraft_core::CommitInfo>,
305 pub file_history_cursor: usize,
307
308 pub blame_path: Option<String>,
310 pub blame_lines: Vec<gitkraft_core::BlameLine>,
312 pub blame_scroll: u16,
314
315 pub confirm_delete_file: Option<String>,
317}
318
319impl RepoTab {
320 #[must_use]
321 pub fn new() -> Self {
322 Self {
323 repo_path: None,
324 repo_info: None,
325
326 branches: Vec::new(),
327 branch_list_state: ListState::default(),
328
329 commits: Vec::new(),
330 graph_rows: Vec::new(),
331 commit_list_state: ListState::default(),
332
333 unstaged_changes: Vec::new(),
334 staged_changes: Vec::new(),
335 unstaged_list_state: ListState::default(),
336 staged_list_state: ListState::default(),
337 staging_focus: StagingFocus::Unstaged,
338 selected_diff: None,
339 diff_scroll: 0,
340 diff_sub_pane: DiffSubPane::FileList,
341 anchor_file_index: None,
342 selected_file_indices: std::collections::HashSet::new(),
343 commit_diffs: std::collections::HashMap::new(),
344 commit_diff_file_index: 0,
345 commit_files: Vec::new(),
346 selected_commit_oid: None,
347
348 stashes: Vec::new(),
349 stash_list_state: ListState::default(),
350 remotes: Vec::new(),
351
352 stash_message_buffer: String::new(),
353
354 search_query: String::new(),
355 search_active: false,
356 search_results: Vec::new(),
357
358 status_message: None,
359 error_message: None,
360
361 is_loading: false,
362
363 confirm_discard: false,
364
365 selected_unstaged: std::collections::HashSet::new(),
366 selected_staged: std::collections::HashSet::new(),
367 anchor_unstaged: None,
368 anchor_staged: None,
369
370 anchor_commit_index: None,
371 selected_commits: Vec::new(),
372 commit_range_diffs: Vec::new(),
373
374 commit_action_items: Vec::new(),
375 commit_action_cursor: 0,
376 pending_commit_action_oid: None,
377 pending_action_kind: None,
378 action_input1: String::new(),
379
380 file_history_path: None,
381 file_history_commits: Vec::new(),
382 file_history_cursor: 0,
383 blame_path: None,
384 blame_lines: Vec::new(),
385 blame_scroll: 0,
386 confirm_delete_file: None,
387 }
388 }
389
390 pub fn display_name(&self) -> String {
393 match &self.repo_path {
394 Some(p) => p
395 .file_name()
396 .map(|n| n.to_string_lossy().into_owned())
397 .unwrap_or_else(|| "New Tab".into()),
398 None => "New Tab".into(),
399 }
400 }
401}
402
403impl Default for RepoTab {
404 fn default() -> Self {
405 Self::new()
406 }
407}
408
409pub struct App {
412 pub should_quit: bool,
413 pub screen: AppScreen,
414 pub active_pane: ActivePane,
415 pub input_mode: InputMode,
416 pub input_purpose: InputPurpose,
417 pub tick_count: u64,
418
419 pub bg_rx: mpsc::Receiver<BackgroundResult>,
421 pub(crate) bg_tx: mpsc::Sender<BackgroundResult>,
423
424 pub input_buffer: String,
425
426 pub show_theme_panel: bool,
428 pub show_options_panel: bool,
430 pub editor: gitkraft_core::Editor,
432 pub show_editor_panel: bool,
434 pub editor_list_state: ListState,
436 pub current_theme_index: usize,
438 pub theme_list_state: ListState,
440
441 pub recent_repos: Vec<gitkraft_core::RepoHistoryEntry>,
443
444 pub browser_dir: PathBuf,
446 pub browser_entries: Vec<std::path::PathBuf>,
448 pub browser_list_state: ListState,
450 pub browser_return_screen: AppScreen,
452
453 pub tabs: Vec<RepoTab>,
455 pub active_tab_index: usize,
457
458 pub pending_editor_open: Option<Vec<std::path::PathBuf>>,
462
463 pub last_auto_refresh: std::time::Instant,
465
466 pub last_refresh_completed: std::time::Instant,
471}
472
473impl App {
474 #[must_use]
477 pub fn new() -> Self {
478 let settings =
479 gitkraft_core::features::persistence::load_tui_settings().unwrap_or_default();
480
481 let theme_index = theme_name_to_index(settings.theme_name.as_deref().unwrap_or(""));
482
483 let recent_repos = settings.recent_repos;
484
485 let (bg_tx, bg_rx) = mpsc::channel();
486
487 Self {
488 should_quit: false,
489 screen: AppScreen::Welcome,
490 active_pane: ActivePane::Branches,
491 input_mode: InputMode::Normal,
492 input_purpose: InputPurpose::None,
493 tick_count: 0,
494
495 bg_rx,
496 bg_tx,
497
498 input_buffer: String::new(),
499
500 show_theme_panel: false,
501 show_options_panel: false,
502 editor: settings
503 .editor_name
504 .as_deref()
505 .map(|name| {
506 gitkraft_core::EDITOR_NAMES
507 .iter()
508 .position(|n| n.eq_ignore_ascii_case(name))
509 .map(gitkraft_core::Editor::from_index)
510 .unwrap_or_else(|| {
511 if name.eq_ignore_ascii_case("none") {
512 gitkraft_core::Editor::None
513 } else {
514 gitkraft_core::Editor::Custom(name.to_string())
515 }
516 })
517 })
518 .unwrap_or(gitkraft_core::Editor::None),
519 show_editor_panel: false,
520 editor_list_state: {
521 let mut s = ListState::default();
522 s.select(Some(0));
523 s
524 },
525 current_theme_index: theme_index,
526 theme_list_state: {
527 let mut s = ListState::default();
528 s.select(Some(theme_index));
529 s
530 },
531
532 recent_repos,
533
534 browser_dir: dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")),
535 browser_entries: Vec::new(),
536 browser_list_state: ListState::default(),
537 browser_return_screen: AppScreen::Welcome,
538
539 tabs: vec![RepoTab::new()],
540 active_tab_index: 0,
541
542 pending_editor_open: None,
543
544 last_auto_refresh: std::time::Instant::now(),
545 last_refresh_completed: std::time::Instant::now() - std::time::Duration::from_secs(100),
546 }
547 }
548
549 #[inline]
553 pub fn tab(&self) -> &RepoTab {
554 &self.tabs[self.active_tab_index]
555 }
556
557 #[inline]
559 pub fn tab_mut(&mut self) -> &mut RepoTab {
560 &mut self.tabs[self.active_tab_index]
561 }
562
563 pub fn new_tab(&mut self) {
567 self.tabs.push(RepoTab::new());
568 self.active_tab_index = self.tabs.len() - 1;
569 self.screen = AppScreen::Welcome;
570 if let Ok(settings) = gitkraft_core::features::persistence::load_tui_settings() {
572 self.recent_repos = settings.recent_repos;
573 }
574 self.save_session();
575 }
576
577 pub fn close_tab(&mut self) {
579 if self.tabs.len() <= 1 {
580 self.tabs[0] = RepoTab::new();
581 self.active_tab_index = 0;
582 } else {
583 self.tabs.remove(self.active_tab_index);
584 if self.active_tab_index >= self.tabs.len() {
585 self.active_tab_index = self.tabs.len() - 1;
586 }
587 }
588 self.save_session();
589 }
590
591 pub fn next_tab(&mut self) {
593 if !self.tabs.is_empty() {
594 self.active_tab_index = (self.active_tab_index + 1) % self.tabs.len();
595 if self.tabs[self.active_tab_index].repo_path.is_some() {
597 self.screen = AppScreen::Main;
598 } else {
599 self.screen = AppScreen::Welcome;
600 }
601 }
602 }
603
604 pub fn prev_tab(&mut self) {
606 if !self.tabs.is_empty() {
607 if self.active_tab_index == 0 {
608 self.active_tab_index = self.tabs.len() - 1;
609 } else {
610 self.active_tab_index -= 1;
611 }
612 if self.tabs[self.active_tab_index].repo_path.is_some() {
614 self.screen = AppScreen::Main;
615 } else {
616 self.screen = AppScreen::Welcome;
617 }
618 }
619 }
620}
621
622impl Default for App {
623 fn default() -> Self {
624 Self::new()
625 }
626}
627
628impl App {
629 pub fn cycle_theme_next(&mut self) {
632 let count = gitkraft_core::THEME_COUNT;
633 self.current_theme_index = (self.current_theme_index + 1) % count;
634 self.theme_list_state.select(Some(self.current_theme_index));
635 self.tab_mut().status_message = Some(format!("Theme: {}", self.current_theme_name()));
636 }
637
638 pub fn cycle_theme_prev(&mut self) {
639 let count = gitkraft_core::THEME_COUNT;
640 if self.current_theme_index == 0 {
641 self.current_theme_index = count - 1;
642 } else {
643 self.current_theme_index -= 1;
644 }
645 self.theme_list_state.select(Some(self.current_theme_index));
646 self.tab_mut().status_message = Some(format!("Theme: {}", self.current_theme_name()));
647 }
648
649 pub fn current_theme_name(&self) -> &'static str {
650 gitkraft_core::THEME_NAMES
651 .get(self.current_theme_index)
652 .copied()
653 .unwrap_or("Default")
654 }
655
656 pub fn theme(&self) -> crate::features::theme::palette::UiTheme {
658 crate::features::theme::palette::theme_for_index(self.current_theme_index)
659 }
660
661 pub fn save_theme(&self) {
663 let _ = gitkraft_core::features::persistence::save_theme_tui(self.current_theme_name());
664 }
665
666 pub fn save_session(&self) {
668 let paths: Vec<std::path::PathBuf> = self
669 .tabs
670 .iter()
671 .filter_map(|t| t.repo_path.clone())
672 .collect();
673 let active = self.active_tab_index;
674 let _ = gitkraft_core::features::persistence::save_session_tui(&paths, active);
675 }
676
677 pub fn open_repo(&mut self, path: PathBuf) {
680 self.tab_mut().error_message = None;
681 self.tab_mut().status_message = Some("Opening repository…".into());
682 self.tab_mut().is_loading = true;
683 self.tab_mut().repo_path = Some(path.clone());
684 self.screen = AppScreen::Main;
685
686 let tx = self.bg_tx.clone();
687 std::thread::spawn(move || {
688 let result = load_repo_blocking(&path);
689 let _ = tx.send(BackgroundResult::RepoLoaded { path, result });
690 });
691 self.save_session();
692 }
693
694 pub fn refresh(&mut self) {
695 self.tab_mut().error_message = None;
696 self.tab_mut().is_loading = true;
697 self.tab_mut().status_message = Some("Refreshing…".into());
698 self.refresh_internal();
699 }
700
701 pub fn refresh_silent(&mut self) {
704 self.tab_mut().error_message = None;
705 self.tab_mut().is_loading = true;
706 self.refresh_internal();
707 }
708
709 fn refresh_internal(&mut self) {
710 let path = match self.tab().repo_path.clone() {
711 Some(p) => p,
712 None => {
713 self.tab_mut().error_message = Some("No repository open".into());
714 self.tab_mut().is_loading = false;
715 return;
716 }
717 };
718
719 let tx = self.bg_tx.clone();
720 std::thread::spawn(move || {
721 let result = load_repo_blocking(&path);
722 let _ = tx.send(BackgroundResult::RepoLoaded { path, result });
723 });
724 }
725
726 pub fn poll_background(&mut self) {
729 while let Ok(result) = self.bg_rx.try_recv() {
730 match result {
731 BackgroundResult::RepoLoaded {
732 path: loaded_path,
733 result: res,
734 } => {
735 let tab_idx = self
737 .tabs
738 .iter()
739 .position(|t| t.repo_path.as_ref() == Some(&loaded_path))
740 .unwrap_or(self.active_tab_index);
741
742 self.tabs[tab_idx].is_loading = false;
743 match res {
744 Ok(payload) => {
745 let canonical = payload.info.workdir.clone().unwrap_or_else(|| {
746 self.tabs[tab_idx].repo_path.clone().unwrap_or_default()
747 });
748 self.tabs[tab_idx].repo_path = Some(canonical.clone());
749
750 let _ = gitkraft_core::features::persistence::record_repo_opened_tui(
752 &canonical,
753 );
754 if let Ok(settings) =
755 gitkraft_core::features::persistence::load_tui_settings()
756 {
757 self.recent_repos = settings.recent_repos;
758 }
759
760 let tab = &mut self.tabs[tab_idx];
761 tab.repo_info = Some(payload.info);
762 tab.branches = payload.branches;
763 clamp_list_state(&mut tab.branch_list_state, tab.branches.len());
764 tab.graph_rows = payload.graph_rows;
765 tab.commits = payload.commits;
766 clamp_list_state(&mut tab.commit_list_state, tab.commits.len());
767 tab.unstaged_changes = payload.unstaged;
768 clamp_list_state(
769 &mut tab.unstaged_list_state,
770 tab.unstaged_changes.len(),
771 );
772 tab.staged_changes = payload.staged;
773 clamp_list_state(&mut tab.staged_list_state, tab.staged_changes.len());
774 tab.stashes = payload.stashes;
775 clamp_list_state(&mut tab.stash_list_state, tab.stashes.len());
776 tab.remotes = payload.remotes;
777 tab.status_message = None;
781 self.screen = AppScreen::Main;
782 self.save_session();
783 self.last_refresh_completed = std::time::Instant::now();
785 }
786 Err(e) => {
787 self.tabs[tab_idx].error_message = Some(e);
788 self.tabs[tab_idx].status_message = None;
789 }
790 }
791 }
792 BackgroundResult::FetchDone(res) => {
793 self.tab_mut().is_loading = false;
794 match res {
795 Ok(()) => {
796 self.tab_mut().status_message = Some("Fetched from origin".into());
797 self.refresh();
798 }
799 Err(e) => self.tab_mut().error_message = Some(format!("fetch: {e}")),
800 }
801 }
802 BackgroundResult::CommitDiffLoaded(res) => {
803 self.tab_mut().is_loading = false;
804 match res {
805 Ok(diffs) => {
806 if diffs.is_empty() {
807 let tab = self.tab_mut();
808 tab.selected_diff = None;
809 tab.commit_diffs.clear();
810 tab.commit_diff_file_index = 0;
811 tab.status_message = Some("No changes in this commit".into());
812 } else {
813 let tab = self.tab_mut();
814 tab.commit_diffs = diffs
815 .iter()
816 .enumerate()
817 .map(|(i, d)| (i, d.clone()))
818 .collect();
819 tab.commit_diff_file_index = 0;
820 tab.selected_diff = Some(diffs[0].clone());
821 tab.diff_scroll = 0;
822 if diffs.len() > 1 {
823 tab.status_message = Some(format!(
824 "Showing file 1/{} — use h/l to switch files",
825 diffs.len()
826 ));
827 }
828 }
829 }
830 Err(e) => self.tab_mut().error_message = Some(format!("commit diff: {e}")),
831 }
832 }
833 BackgroundResult::CommitFileListLoaded(res) => {
834 self.tab_mut().is_loading = false;
835 match res {
836 Ok(files) => {
837 let count = files.len();
838 let tab = self.tab_mut();
839 tab.commit_files = files;
840 tab.commit_diffs.clear();
841 tab.commit_diff_file_index = 0;
842 tab.selected_diff = None;
843 tab.diff_scroll = 0;
844 tab.diff_sub_pane = DiffSubPane::FileList;
845 tab.selected_file_indices.clear();
846
847 if count == 0 {
848 tab.status_message = Some("No changes in this commit".into());
849 } else {
850 tab.status_message = Some(format!("{count} file(s) changed"));
851 tab.selected_file_indices.insert(0);
852 let first_path = tab.commit_files[0].display_path().to_string();
854 self.load_single_file_diff(0, first_path);
855 }
856 }
857 Err(e) => self.tab_mut().error_message = Some(format!("file list: {e}")),
858 }
859 }
860 BackgroundResult::SingleFileDiffLoaded(res) => {
861 self.tab_mut().is_loading = false;
862 match res {
863 Ok((file_index, diff)) => {
864 let tab = self.tab_mut();
865 tab.commit_diffs.insert(file_index, diff.clone());
866 let is_multi = tab.selected_file_indices.len() > 1;
869 if !is_multi && file_index == tab.commit_diff_file_index {
870 tab.selected_diff = Some(diff);
871 tab.diff_scroll = 0;
872 }
873 if tab.commit_files.len() > 1 {
874 let sel_count = tab.selected_file_indices.len();
875 if sel_count > 1 {
876 tab.status_message = Some(format!(
877 "{sel_count} files selected — use Shift+↑/↓ to adjust"
878 ));
879 } else {
880 tab.status_message = Some(format!(
881 "File {}/{} — use h/l or ↑/↓ to switch",
882 file_index + 1,
883 tab.commit_files.len()
884 ));
885 }
886 }
887 }
888 Err(e) => self.tab_mut().error_message = Some(format!("file diff: {e}")),
889 }
890 }
891 BackgroundResult::StagingRefreshed(res) => {
892 self.tab_mut().is_loading = false;
893 match res {
894 Ok(payload) => self.apply_staging_payload(payload),
895 Err(e) => {
896 self.tab_mut().error_message = Some(format!("staging refresh: {e}"))
897 }
898 }
899 }
900 BackgroundResult::OperationDone {
901 ok_message,
902 err_message,
903 needs_refresh,
904 needs_staging_refresh,
905 } => {
906 self.tab_mut().is_loading = false;
907 if let Some(msg) = err_message {
908 self.tab_mut().error_message = Some(msg);
909 } else if let Some(msg) = ok_message {
910 self.tab_mut().status_message = Some(msg);
911 }
912 if needs_refresh {
913 self.refresh();
914 } else if needs_staging_refresh {
915 self.refresh_staging();
916 }
917 }
918 BackgroundResult::SearchResults(res) => match res {
919 Ok(results) => {
920 self.tab_mut().search_results = results;
921 let count = self.tab().search_results.len();
922 self.tab_mut().status_message = Some(format!("{count} result(s) found"));
923 }
924 Err(e) => {
925 self.tab_mut().error_message = Some(format!("Search failed: {e}"));
926 }
927 },
928 BackgroundResult::CommitRangeDiffLoaded(res) => {
929 self.tab_mut().is_loading = false;
930 match res {
931 Ok(diffs) => {
932 let count = self.tab().selected_commits.len();
933 let tab = self.tab_mut();
934 tab.commit_range_diffs = diffs;
935 tab.diff_scroll = 0;
936 tab.status_message = Some(format!(
937 "Combined diff for {count} commits — use j/k to scroll"
938 ));
939 }
940 Err(e) => {
941 self.tab_mut().error_message = Some(format!("Range diff: {e}"));
942 }
943 }
944 }
945
946 BackgroundResult::FileHistoryLoaded { path, commits } => {
947 let count = commits.len();
948 let file_name = path.rsplit('/').next().unwrap_or(&path).to_string();
949 let tab = self.tab_mut();
950 tab.file_history_path = Some(path);
951 tab.file_history_commits = commits;
952 tab.file_history_cursor = 0;
953 tab.status_message = Some(format!("History: {file_name} ({count} commits)"));
954 }
955
956 BackgroundResult::FileBlameLoaded { path, lines } => {
957 let count = lines.len();
958 let file_name = path.rsplit('/').next().unwrap_or(&path).to_string();
959 let tab = self.tab_mut();
960 tab.blame_path = Some(path);
961 tab.blame_lines = lines;
962 tab.blame_scroll = 0;
963 tab.status_message = Some(format!("Blame: {file_name} ({count} lines)"));
964 }
965
966 BackgroundResult::GitStateChanged => {
967 const WATCHER_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(2);
971 if !self.tab().is_loading
972 && self.last_refresh_completed.elapsed() >= WATCHER_COOLDOWN
973 {
974 self.refresh_silent();
975 }
976 }
977 }
978 }
979 }
980
981 pub fn refresh_staging(&mut self) {
987 let repo_path = match self.tab().repo_path.clone() {
988 Some(p) => p,
989 None => {
990 self.tab_mut().error_message = Some("No repository open".into());
991 return;
992 }
993 };
994 let tx = self.bg_tx.clone();
995 std::thread::spawn(move || {
996 let res = (|| {
997 let repo = open_repo_str(&repo_path)?;
998 let unstaged = gitkraft_core::features::diff::get_working_dir_diff(&repo)
999 .map_err(|e| e.to_string())?;
1000 let staged = gitkraft_core::features::diff::get_staged_diff(&repo)
1001 .map_err(|e| e.to_string())?;
1002 Ok::<_, String>(StagingPayload { unstaged, staged })
1003 })();
1004 let _ = tx.send(BackgroundResult::StagingRefreshed(res));
1005 });
1006 }
1007
1008 fn apply_staging_payload(&mut self, payload: StagingPayload) {
1009 self.tab_mut().selected_unstaged.clear();
1010 self.tab_mut().selected_staged.clear();
1011 let tab = self.tab_mut();
1012 tab.unstaged_changes = payload.unstaged;
1013 if tab.unstaged_changes.is_empty() {
1014 tab.unstaged_list_state.select(None);
1015 } else if tab.unstaged_list_state.selected().is_none() {
1016 tab.unstaged_list_state.select(Some(0));
1017 } else if let Some(i) = tab.unstaged_list_state.selected() {
1018 if i >= tab.unstaged_changes.len() {
1019 tab.unstaged_list_state
1020 .select(Some(tab.unstaged_changes.len() - 1));
1021 }
1022 }
1023
1024 tab.staged_changes = payload.staged;
1025 if tab.staged_changes.is_empty() {
1026 tab.staged_list_state.select(None);
1027 } else if tab.staged_list_state.selected().is_none() {
1028 tab.staged_list_state.select(Some(0));
1029 } else if let Some(i) = tab.staged_list_state.selected() {
1030 if i >= tab.staged_changes.len() {
1031 tab.staged_list_state
1032 .select(Some(tab.staged_changes.len() - 1));
1033 }
1034 }
1035 }
1036
1037 pub fn stage_selected(&mut self) {
1040 let idx = require_selection!(self, unstaged_list_state, "No unstaged file selected");
1041 let file_path = self.unstaged_file_path(idx);
1042 bg_op!(self, "Staging…", staging, |repo_path| {
1043 let repo = open_repo_str(&repo_path)?;
1044 gitkraft_core::features::staging::stage_file(&repo, &file_path)
1045 .map_err(|e| format!("stage: {e}"))?;
1046 Ok(format!("Staged: {file_path}"))
1047 });
1048 }
1049
1050 pub fn unstage_selected(&mut self) {
1051 let idx = require_selection!(self, staged_list_state, "No staged file selected");
1052 let file_path = self.staged_file_path(idx);
1053 bg_op!(self, "Unstaging…", staging, |repo_path| {
1054 let repo = open_repo_str(&repo_path)?;
1055 gitkraft_core::features::staging::unstage_file(&repo, &file_path)
1056 .map_err(|e| format!("unstage: {e}"))?;
1057 Ok(format!("Unstaged: {file_path}"))
1058 });
1059 }
1060
1061 pub fn stage_all(&mut self) {
1062 bg_op!(self, "Staging all…", staging, |repo_path| {
1063 let repo = open_repo_str(&repo_path)?;
1064 gitkraft_core::features::staging::stage_all(&repo)
1065 .map_err(|e| format!("stage all: {e}"))?;
1066 Ok("Staged all files".into())
1067 });
1068 }
1069
1070 pub fn unstage_all(&mut self) {
1071 bg_op!(self, "Unstaging all…", staging, |repo_path| {
1072 let repo = open_repo_str(&repo_path)?;
1073 gitkraft_core::features::staging::unstage_all(&repo)
1074 .map_err(|e| format!("unstage all: {e}"))?;
1075 Ok("Unstaged all files".into())
1076 });
1077 }
1078
1079 pub fn discard_selected(&mut self) {
1080 let idx = require_selection!(self, unstaged_list_state, "No unstaged file selected");
1081 let file_path = self.unstaged_file_path(idx);
1082 self.tab_mut().confirm_discard = false;
1083 bg_op!(self, "Discarding…", staging, |repo_path| {
1084 let repo = open_repo_str(&repo_path)?;
1085 gitkraft_core::features::staging::discard_file_changes(&repo, &file_path)
1086 .map_err(|e| format!("discard: {e}"))?;
1087 Ok(format!("Discarded changes: {file_path}"))
1088 });
1089 }
1090
1091 pub fn stage_files(&mut self, paths: Vec<String>) {
1093 let count = paths.len();
1094 bg_op!(
1095 self,
1096 format!("Staging {count} file(s)…"),
1097 staging,
1098 |repo_path| {
1099 let repo = open_repo_str(&repo_path)?;
1100 for fp in &paths {
1101 gitkraft_core::features::staging::stage_file(&repo, fp)
1102 .map_err(|e| e.to_string())?;
1103 }
1104 Ok(format!("{count} file(s) staged"))
1105 }
1106 );
1107 }
1108
1109 pub fn unstage_files(&mut self, paths: Vec<String>) {
1111 let count = paths.len();
1112 bg_op!(
1113 self,
1114 format!("Unstaging {count} file(s)…"),
1115 staging,
1116 |repo_path| {
1117 let repo = open_repo_str(&repo_path)?;
1118 for fp in &paths {
1119 gitkraft_core::features::staging::unstage_file(&repo, fp)
1120 .map_err(|e| e.to_string())?;
1121 }
1122 Ok(format!("{count} file(s) unstaged"))
1123 }
1124 );
1125 }
1126
1127 pub fn discard_files(&mut self, paths: Vec<String>) {
1129 let count = paths.len();
1130 bg_op!(
1131 self,
1132 format!("Discarding {count} file(s)…"),
1133 staging,
1134 |repo_path| {
1135 let repo = open_repo_str(&repo_path)?;
1136 for fp in &paths {
1137 gitkraft_core::features::staging::discard_file_changes(&repo, fp)
1138 .map_err(|e| e.to_string())?;
1139 }
1140 Ok(format!("{count} file(s) discarded"))
1141 }
1142 );
1143 }
1144
1145 pub fn create_commit(&mut self) {
1148 let msg = self.input_buffer.trim().to_string();
1149 if msg.is_empty() {
1150 self.tab_mut().error_message = Some("Commit message cannot be empty".into());
1151 return;
1152 }
1153 self.input_buffer.clear();
1154 bg_op!(self, "Committing…", refresh, |repo_path| {
1155 let repo = open_repo_str(&repo_path)?;
1156 let info = gitkraft_core::features::commits::create_commit(&repo, &msg)
1157 .map_err(|e| format!("commit: {e}"))?;
1158 Ok(format!("Committed: {} {}", info.short_oid, info.summary))
1159 });
1160 }
1161
1162 pub fn checkout_selected_branch(&mut self) {
1165 let idx = selected_idx!(self, branch_list_state, branches, "No branch selected");
1166 let name = self.tab().branches[idx].name.clone();
1167 if self.tab().branches[idx].is_head {
1168 self.tab_mut().status_message = Some(format!("Already on '{name}'"));
1169 return;
1170 }
1171 bg_op!(self, "Checking out…", refresh, |repo_path| {
1172 let repo = open_repo_str(&repo_path)?;
1173 gitkraft_core::features::branches::checkout_branch(&repo, &name)
1174 .map_err(|e| format!("checkout: {e}"))?;
1175 Ok(format!("Checked out: {name}"))
1176 });
1177 }
1178
1179 pub fn create_branch(&mut self) {
1180 let name = self.input_buffer.trim().to_string();
1181 if name.is_empty() {
1182 self.tab_mut().error_message = Some("Branch name cannot be empty".into());
1183 return;
1184 }
1185 self.input_buffer.clear();
1186 bg_op!(self, "Creating branch…", refresh, |repo_path| {
1187 let repo = open_repo_str(&repo_path)?;
1188 gitkraft_core::features::branches::create_branch(&repo, &name)
1189 .map_err(|e| format!("create branch: {e}"))?;
1190 Ok(format!("Created branch: {name}"))
1191 });
1192 }
1193
1194 pub fn delete_selected_branch(&mut self) {
1195 let idx = selected_idx!(self, branch_list_state, branches, "No branch selected");
1196 if self.tab().branches[idx].is_head {
1197 self.tab_mut().error_message = Some("Cannot delete the current branch".into());
1198 return;
1199 }
1200 let name = self.tab().branches[idx].name.clone();
1201 bg_op!(self, "Deleting branch…", refresh, |repo_path| {
1202 let repo = open_repo_str(&repo_path)?;
1203 gitkraft_core::features::branches::delete_branch(&repo, &name)
1204 .map_err(|e| format!("delete branch: {e}"))?;
1205 Ok(format!("Deleted branch: {name}"))
1206 });
1207 }
1208
1209 pub fn stash_save(&mut self) {
1212 let msg = if self.tab().stash_message_buffer.trim().is_empty() {
1213 None
1214 } else {
1215 Some(self.tab().stash_message_buffer.trim().to_string())
1216 };
1217 self.tab_mut().stash_message_buffer.clear();
1218 bg_op!(self, "Stashing…", refresh, |repo_path| {
1219 let mut repo = open_repo_str(&repo_path)?;
1220 let entry = gitkraft_core::features::stash::stash_save(&mut repo, msg.as_deref())
1221 .map_err(|e| format!("stash save: {e}"))?;
1222 Ok(format!("Stashed: {}", entry.message))
1223 });
1224 }
1225
1226 pub fn stash_pop_selected(&mut self) {
1227 let idx = selected_idx!(self, stash_list_state, stashes, "No stash selected");
1228 bg_op!(self, "Popping stash…", refresh, |repo_path| {
1229 let mut repo = open_repo_str(&repo_path)?;
1230 gitkraft_core::features::stash::stash_pop(&mut repo, idx)
1231 .map_err(|e| format!("stash pop: {e}"))?;
1232 Ok(format!("Stash @{{{idx}}} popped"))
1233 });
1234 }
1235
1236 pub fn stash_drop_selected(&mut self) {
1237 let idx = selected_idx!(self, stash_list_state, stashes, "No stash to drop");
1238 bg_op!(self, "Dropping stash…", refresh, |repo_path| {
1239 let mut repo = open_repo_str(&repo_path)?;
1240 gitkraft_core::features::stash::stash_drop(&mut repo, idx)
1241 .map_err(|e| format!("stash drop: {e}"))?;
1242 Ok(format!("Stash @{{{idx}}} dropped"))
1243 });
1244 }
1245
1246 pub fn load_commit_diff(&mut self) {
1250 let idx = match self.tab().commit_list_state.selected() {
1251 Some(i) => i,
1252 None => return,
1253 };
1254 if idx >= self.tab().commits.len() {
1255 return;
1256 }
1257 let oid = self.tab().commits[idx].oid.clone();
1258 self.tab_mut().selected_commit_oid = Some(oid.clone());
1259 bg_task!(
1260 self,
1261 "Loading files…",
1262 BackgroundResult::CommitFileListLoaded,
1263 |repo_path| {
1264 let repo = open_repo_str(&repo_path)?;
1265 gitkraft_core::features::diff::get_commit_file_list(&repo, &oid)
1266 .map_err(|e| e.to_string())
1267 }
1268 );
1269 }
1270
1271 pub fn load_single_file_diff(&mut self, file_index: usize, file_path: String) {
1273 let oid = match self.tab().selected_commit_oid.clone() {
1274 Some(o) => o,
1275 None => return,
1276 };
1277 bg_task!(
1278 self,
1279 "Loading diff…",
1280 BackgroundResult::SingleFileDiffLoaded,
1281 |repo_path| {
1282 let repo = open_repo_str(&repo_path)?;
1283 let diff =
1284 gitkraft_core::features::diff::get_single_file_diff(&repo, &oid, &file_path)
1285 .map_err(|e| e.to_string())?;
1286 Ok((file_index, diff))
1287 }
1288 );
1289 }
1290
1291 pub fn load_diff_for_file_index(&mut self, file_index: usize) {
1293 if file_index >= self.tab().commit_files.len() {
1294 return;
1295 }
1296 if self.tab().commit_diffs.contains_key(&file_index) {
1297 return;
1298 }
1299 let file_path = self.tab().commit_files[file_index]
1300 .display_path()
1301 .to_string();
1302 self.load_single_file_diff(file_index, file_path);
1303 }
1304
1305 pub fn next_diff_file(&mut self) {
1307 if self.tab().commit_files.is_empty() {
1308 return;
1309 }
1310 let new_index = (self.tab().commit_diff_file_index + 1) % self.tab().commit_files.len();
1311 self.tab_mut().anchor_file_index = Some(new_index);
1312 self.tab_mut().commit_diff_file_index = new_index;
1313 self.tab_mut().selected_file_indices.clear();
1314 self.tab_mut().selected_file_indices.insert(new_index);
1315 self.tab_mut().diff_scroll = 0;
1316 self.tab_mut().status_message = Some(format!(
1317 "File {}/{}",
1318 new_index + 1,
1319 self.tab().commit_files.len()
1320 ));
1321 if let Some(cached) = self.tab().commit_diffs.get(&new_index).cloned() {
1322 self.tab_mut().selected_diff = Some(cached);
1323 } else {
1324 let file_path = self.tab().commit_files[new_index]
1325 .display_path()
1326 .to_string();
1327 self.load_single_file_diff(new_index, file_path);
1328 }
1329 }
1330
1331 pub fn prev_diff_file(&mut self) {
1333 if self.tab().commit_files.is_empty() {
1334 return;
1335 }
1336 let new_index = if self.tab().commit_diff_file_index == 0 {
1337 self.tab().commit_files.len() - 1
1338 } else {
1339 self.tab().commit_diff_file_index - 1
1340 };
1341 self.tab_mut().anchor_file_index = Some(new_index);
1342 self.tab_mut().commit_diff_file_index = new_index;
1343 self.tab_mut().selected_file_indices.clear();
1344 self.tab_mut().selected_file_indices.insert(new_index);
1345 self.tab_mut().diff_scroll = 0;
1346 self.tab_mut().status_message = Some(format!(
1347 "File {}/{}",
1348 new_index + 1,
1349 self.tab().commit_files.len()
1350 ));
1351 if let Some(cached) = self.tab().commit_diffs.get(&new_index).cloned() {
1352 self.tab_mut().selected_diff = Some(cached);
1353 } else {
1354 let file_path = self.tab().commit_files[new_index]
1355 .display_path()
1356 .to_string();
1357 self.load_single_file_diff(new_index, file_path);
1358 }
1359 }
1360
1361 pub fn search_commits(&mut self, query: String) {
1364 let repo_path = match self.tab().repo_path.clone() {
1365 Some(p) => p,
1366 None => return,
1367 };
1368 self.tab_mut().search_query = query.clone();
1369 if query.trim().len() < 2 {
1370 self.tab_mut().search_results.clear();
1371 return;
1372 }
1373 let tx = self.bg_tx.clone();
1374 std::thread::spawn(move || {
1375 let res = (|| {
1376 let repo = open_repo_str(&repo_path)?;
1377 gitkraft_core::features::log::search_commits(&repo, &query, 100)
1378 .map_err(|e| e.to_string())
1379 })();
1380 let _ = tx.send(BackgroundResult::SearchResults(res));
1381 });
1382 }
1383
1384 pub fn load_commit_diff_by_oid(&mut self) {
1386 let oid = match self.tab().selected_commit_oid.clone() {
1387 Some(o) => o,
1388 None => return,
1389 };
1390 bg_task!(
1391 self,
1392 "Loading files…",
1393 BackgroundResult::CommitFileListLoaded,
1394 |repo_path| {
1395 let repo = open_repo_str(&repo_path)?;
1396 gitkraft_core::features::diff::get_commit_file_list(&repo, &oid)
1397 .map_err(|e| e.to_string())
1398 }
1399 );
1400 }
1401
1402 pub fn load_commit_range_diff(&mut self) {
1404 let selected = self.tab().selected_commits.clone();
1405 if selected.len() < 2 {
1406 return;
1407 }
1408 let oldest_idx = *selected.last().unwrap();
1410 let newest_idx = selected[0];
1411
1412 let oldest_oid = match self.tab().commits.get(oldest_idx).map(|c| c.oid.clone()) {
1413 Some(o) => o,
1414 None => return,
1415 };
1416 let newest_oid = match self.tab().commits.get(newest_idx).map(|c| c.oid.clone()) {
1417 Some(o) => o,
1418 None => return,
1419 };
1420
1421 bg_task!(
1422 self,
1423 "Loading range diff…",
1424 BackgroundResult::CommitRangeDiffLoaded,
1425 |repo_path| {
1426 let repo = open_repo_str(&repo_path)?;
1427 gitkraft_core::features::diff::get_commit_range_diff(
1428 &repo,
1429 &oldest_oid,
1430 &newest_oid,
1431 )
1432 .map_err(|e| e.to_string())
1433 }
1434 );
1435 }
1436
1437 pub fn close_repo(&mut self) {
1438 self.tabs[self.active_tab_index] = RepoTab::new();
1439 self.input_buffer.clear();
1440 self.show_theme_panel = false;
1441 self.show_options_panel = false;
1442 self.screen = AppScreen::Welcome;
1443 if let Ok(settings) = gitkraft_core::features::persistence::load_tui_settings() {
1445 self.recent_repos = settings.recent_repos;
1446 }
1447 self.save_session();
1448 }
1449
1450 pub fn refresh_browser(&mut self) {
1452 let mut entries = Vec::new();
1453 if let Ok(read_dir) = std::fs::read_dir(&self.browser_dir) {
1454 for entry in read_dir.flatten() {
1455 let path = entry.path();
1456 if path.is_dir() {
1458 entries.push(path);
1459 }
1460 }
1461 }
1462 entries.sort_by(|a, b| {
1463 let a_name = a
1464 .file_name()
1465 .unwrap_or_default()
1466 .to_string_lossy()
1467 .to_lowercase();
1468 let b_name = b
1469 .file_name()
1470 .unwrap_or_default()
1471 .to_string_lossy()
1472 .to_lowercase();
1473 let a_dot = a_name.starts_with('.');
1475 let b_dot = b_name.starts_with('.');
1476 a_dot.cmp(&b_dot).then(a_name.cmp(&b_name))
1477 });
1478 self.browser_entries = entries;
1479 self.browser_list_state = ListState::default();
1480 if !self.browser_entries.is_empty() {
1481 self.browser_list_state.select(Some(0));
1482 }
1483 }
1484
1485 pub fn open_browser(&mut self, start: PathBuf) {
1487 self.browser_return_screen = self.screen.clone();
1488 self.browser_dir = start;
1489 self.refresh_browser();
1490 self.screen = AppScreen::DirBrowser;
1491 }
1492 pub fn open_settings_in_editor(&mut self) {
1495 let path = match gitkraft_core::features::persistence::ops::tui_settings_json_path() {
1496 Ok(p) => p,
1497 Err(e) => {
1498 self.tab_mut().error_message = Some(format!("Cannot determine settings path: {e}"));
1499 return;
1500 }
1501 };
1502
1503 if !path.exists() {
1505 let snap =
1506 gitkraft_core::features::persistence::load_tui_settings().unwrap_or_default();
1507 let _ = gitkraft_core::features::persistence::save_tui_settings(&snap);
1508 }
1509
1510 let path_str = path.display().to_string();
1511
1512 if self.editor.is_terminal_editor() {
1513 self.tab_mut().status_message =
1517 Some(format!("Opening settings in {} — {path_str}", self.editor));
1518 self.pending_editor_open = Some(vec![path]);
1519 } else if !matches!(self.editor, gitkraft_core::Editor::None) {
1520 match self.editor.open_file(&path) {
1524 Ok(()) => {
1525 self.tab_mut().status_message =
1526 Some(format!("Settings opened in {} — {path_str}", self.editor));
1527 }
1528 Err(e) => {
1529 self.tab_mut().error_message =
1530 Some(format!("Could not open settings ({e}) — path: {path_str}"));
1531 }
1532 }
1533 } else {
1534 self.tab_mut().status_message = Some(format!(
1537 "Settings: {path_str} \
1538 (no editor configured — press E to choose one, or set editor in GUI)"
1539 ));
1540 }
1541 }
1542
1543 pub fn open_selected_in_editor(&mut self) {
1546 if matches!(self.editor, gitkraft_core::Editor::None) {
1547 self.tab_mut().status_message =
1548 Some("No editor configured — press E to choose one".into());
1549 return;
1550 }
1551 let file_path = match self.tab().staging_focus {
1552 StagingFocus::Unstaged => self
1553 .tab()
1554 .unstaged_list_state
1555 .selected()
1556 .and_then(|idx| self.tab().unstaged_changes.get(idx))
1557 .map(|d| d.display_path().to_string()),
1558 StagingFocus::Staged => self
1559 .tab()
1560 .staged_list_state
1561 .selected()
1562 .and_then(|idx| self.tab().staged_changes.get(idx))
1563 .map(|d| d.display_path().to_string()),
1564 };
1565 if let (Some(fp), Some(repo_path)) = (file_path, self.tab().repo_path.as_ref()) {
1566 let full_path = repo_path.join(&fp);
1567 if self.editor.is_terminal_editor() {
1568 self.tab_mut().status_message = Some(format!(
1571 "Opening {} in {} — suspending TUI",
1572 fp, self.editor
1573 ));
1574 self.pending_editor_open = Some(vec![full_path]);
1575 } else {
1576 match self.editor.open_file_or_default(&full_path) {
1577 Ok(method) => {
1578 self.tab_mut().status_message =
1579 Some(format!("Opened {} in {}", fp, method));
1580 }
1581 Err(e) => {
1582 self.tab_mut().error_message = Some(format!("Failed to open editor: {e}"));
1583 }
1584 }
1585 }
1586 }
1587 }
1588
1589 pub fn open_commit_files_in_editor(&mut self) {
1594 let repo_path = match self.tab().repo_path.clone() {
1595 Some(p) => p,
1596 None => {
1597 self.tab_mut().status_message = Some("No repository open".into());
1598 return;
1599 }
1600 };
1601
1602 let indices: Vec<usize> = if self.tab().selected_file_indices.len() > 1 {
1604 let mut v: Vec<usize> = self.tab().selected_file_indices.iter().copied().collect();
1605 v.sort_unstable();
1606 v
1607 } else {
1608 vec![self.tab().commit_diff_file_index]
1609 };
1610
1611 let paths: Vec<std::path::PathBuf> = indices
1612 .iter()
1613 .filter_map(|&i| {
1614 self.tab()
1615 .commit_files
1616 .get(i)
1617 .map(|f| repo_path.join(f.display_path()))
1618 })
1619 .collect();
1620
1621 if paths.is_empty() {
1622 return;
1623 }
1624
1625 let path_strs: Vec<String> = paths.iter().map(|p| p.display().to_string()).collect();
1626 let summary = if path_strs.len() == 1 {
1627 path_strs[0].clone()
1628 } else {
1629 format!("{} files", path_strs.len())
1630 };
1631
1632 if self.editor.is_terminal_editor() {
1633 self.tab_mut().status_message = Some(format!(
1634 "Opening {} in {} — suspending TUI",
1635 summary, self.editor
1636 ));
1637 self.pending_editor_open = Some(paths);
1638 } else if !matches!(self.editor, gitkraft_core::Editor::None) {
1639 let mut last_error: Option<String> = None;
1641 for path in &paths {
1642 if let Err(e) = self.editor.open_file(path) {
1643 last_error = Some(format!("{e}"));
1644 }
1645 }
1646 if let Some(e) = last_error {
1647 self.tab_mut().error_message = Some(format!("Failed to open in editor: {e}"));
1648 } else {
1649 self.tab_mut().status_message =
1650 Some(format!("Opened {} in {}", summary, self.editor));
1651 }
1652 } else {
1653 self.tab_mut().status_message = Some(format!(
1654 "Files: {} (no editor configured — press E to choose one)",
1655 path_strs.join(", ")
1656 ));
1657 }
1658 }
1659
1660 pub fn load_staging_diff(&mut self) {
1661 match self.tab().staging_focus {
1662 StagingFocus::Unstaged => {
1663 if let Some(idx) = self.tab().unstaged_list_state.selected() {
1664 if idx < self.tab().unstaged_changes.len() {
1665 let diff = self.tab().unstaged_changes[idx].clone();
1666 let tab = self.tab_mut();
1667 tab.selected_diff = Some(diff);
1668 tab.diff_scroll = 0;
1669 }
1670 }
1671 }
1672 StagingFocus::Staged => {
1673 if let Some(idx) = self.tab().staged_list_state.selected() {
1674 if idx < self.tab().staged_changes.len() {
1675 let diff = self.tab().staged_changes[idx].clone();
1676 let tab = self.tab_mut();
1677 tab.selected_diff = Some(diff);
1678 tab.diff_scroll = 0;
1679 }
1680 }
1681 }
1682 }
1683 }
1684
1685 pub fn fetch_remote(&mut self) {
1688 let repo_path = match self.tab().repo_path.clone() {
1689 Some(p) => p,
1690 None => return,
1691 };
1692 self.tab_mut().is_loading = true;
1693 self.tab_mut().status_message = Some("Fetching…".into());
1694 let tx = self.bg_tx.clone();
1695 std::thread::spawn(move || {
1696 let res = (|| {
1697 let repo = open_repo_str(&repo_path)?;
1698 gitkraft_core::features::remotes::fetch_remote(&repo, "origin")
1699 .map_err(|e| e.to_string())
1700 })();
1701 let _ = tx.send(BackgroundResult::FetchDone(res));
1702 });
1703 }
1704
1705 pub fn pull_rebase(&mut self) {
1706 let repo_path = match self.tab().repo_path.clone() {
1707 Some(p) => p,
1708 None => return,
1709 };
1710 self.tab_mut().is_loading = true;
1711 self.tab_mut().status_message = Some("Pulling (rebase)…".into());
1712 let tx = self.bg_tx.clone();
1713 std::thread::spawn(move || {
1714 let workdir = std::path::Path::new(&repo_path);
1715 let res = gitkraft_core::features::branches::pull_rebase(workdir, "origin");
1716 let _ = tx.send(BackgroundResult::OperationDone {
1717 ok_message: res
1718 .as_ref()
1719 .ok()
1720 .map(|_| "Pulled (rebase) from origin".into()),
1721 err_message: res.err().map(|e| format!("pull: {e}")),
1722 needs_refresh: true,
1723 needs_staging_refresh: false,
1724 });
1725 });
1726 }
1727
1728 pub fn push_branch(&mut self) {
1729 let repo_path = match self.tab().repo_path.clone() {
1730 Some(p) => p,
1731 None => return,
1732 };
1733 let branch = match self
1734 .tab()
1735 .repo_info
1736 .as_ref()
1737 .and_then(|i| i.head_branch.clone())
1738 {
1739 Some(b) => b,
1740 None => {
1741 self.tab_mut().error_message = Some("No branch checked out".into());
1742 return;
1743 }
1744 };
1745 self.tab_mut().is_loading = true;
1746 self.tab_mut().status_message = Some(format!("Pushing {branch}…"));
1747 let tx = self.bg_tx.clone();
1748 std::thread::spawn(move || {
1749 let workdir = std::path::Path::new(&repo_path);
1750 let res = gitkraft_core::features::branches::push_branch(workdir, &branch, "origin");
1751 let _ = tx.send(BackgroundResult::OperationDone {
1752 ok_message: res
1753 .as_ref()
1754 .ok()
1755 .map(|_| format!("Pushed {branch} to origin")),
1756 err_message: res.err().map(|e| format!("push: {e}")),
1757 needs_refresh: true,
1758 needs_staging_refresh: false,
1759 });
1760 });
1761 }
1762
1763 pub fn force_push_branch(&mut self) {
1764 let repo_path = match self.tab().repo_path.clone() {
1765 Some(p) => p,
1766 None => return,
1767 };
1768 let branch = match self
1769 .tab()
1770 .repo_info
1771 .as_ref()
1772 .and_then(|i| i.head_branch.clone())
1773 {
1774 Some(b) => b,
1775 None => {
1776 self.tab_mut().error_message = Some("No branch checked out".into());
1777 return;
1778 }
1779 };
1780 self.tab_mut().is_loading = true;
1781 self.tab_mut().status_message = Some(format!("Force pushing {branch}…"));
1782 let tx = self.bg_tx.clone();
1783 std::thread::spawn(move || {
1784 let workdir = std::path::Path::new(&repo_path);
1785 let res =
1786 gitkraft_core::features::branches::force_push_branch(workdir, &branch, "origin");
1787 let _ = tx.send(BackgroundResult::OperationDone {
1788 ok_message: res
1789 .as_ref()
1790 .ok()
1791 .map(|_| format!("Force pushed {branch} to origin")),
1792 err_message: res.err().map(|e| format!("force push: {e}")),
1793 needs_refresh: true,
1794 needs_staging_refresh: false,
1795 });
1796 });
1797 }
1798
1799 pub fn merge_selected_branch(&mut self) {
1800 let repo_path = match self.tab().repo_path.clone() {
1801 Some(p) => p,
1802 None => return,
1803 };
1804 let branch_name = match self.tab().branch_list_state.selected() {
1805 Some(idx) => match self.tab().branches.get(idx) {
1806 Some(b) => b.name.clone(),
1807 None => return,
1808 },
1809 None => return,
1810 };
1811 self.tab_mut().is_loading = true;
1812 self.tab_mut().status_message = Some(format!("Merging {branch_name}…"));
1813 let tx = self.bg_tx.clone();
1814 std::thread::spawn(move || {
1815 let res = (|| {
1816 let repo = open_repo_str(&repo_path)?;
1817 gitkraft_core::features::branches::merge_branch(&repo, &branch_name)
1818 .map_err(|e| e.to_string())
1819 })();
1820 let _ = tx.send(BackgroundResult::OperationDone {
1821 ok_message: res.as_ref().ok().map(|_| format!("Merged {branch_name}")),
1822 err_message: res.err(),
1823 needs_refresh: true,
1824 needs_staging_refresh: false,
1825 });
1826 });
1827 }
1828
1829 pub fn rebase_onto_selected_branch(&mut self) {
1830 let repo_path = match self.tab().repo_path.clone() {
1831 Some(p) => p,
1832 None => return,
1833 };
1834 let branch_name = match self.tab().branch_list_state.selected() {
1835 Some(idx) => match self.tab().branches.get(idx) {
1836 Some(b) => b.name.clone(),
1837 None => return,
1838 },
1839 None => return,
1840 };
1841 self.tab_mut().is_loading = true;
1842 self.tab_mut().status_message = Some(format!("Rebasing onto {branch_name}…"));
1843 let tx = self.bg_tx.clone();
1844 std::thread::spawn(move || {
1845 let workdir = std::path::Path::new(&repo_path);
1846 let res = gitkraft_core::features::branches::rebase_onto(workdir, &branch_name);
1847 let _ = tx.send(BackgroundResult::OperationDone {
1848 ok_message: res
1849 .as_ref()
1850 .ok()
1851 .map(|_| format!("Rebased onto {branch_name}")),
1852 err_message: res.err().map(|e| format!("rebase: {e}")),
1853 needs_refresh: true,
1854 needs_staging_refresh: false,
1855 });
1856 });
1857 }
1858
1859 pub fn open_commit_action_popup(&mut self) {
1861 let idx = match self.tab().commit_list_state.selected() {
1862 Some(i) => i,
1863 None => return,
1864 };
1865 let oid = match self.tab().commits.get(idx).map(|c| c.oid.clone()) {
1866 Some(o) => o,
1867 None => return,
1868 };
1869 let items: Vec<gitkraft_core::CommitActionKind> = gitkraft_core::COMMIT_MENU_GROUPS
1871 .iter()
1872 .flat_map(|g| g.iter().copied())
1873 .collect();
1874 let tab = self.tab_mut();
1875 tab.pending_commit_action_oid = Some(oid);
1876 tab.commit_action_items = items;
1877 tab.commit_action_cursor = 0;
1878 }
1879
1880 pub fn execute_commit_action(&mut self, action: gitkraft_core::CommitAction) {
1882 let oid = match self.tab().pending_commit_action_oid.clone() {
1883 Some(o) => o,
1884 None => return,
1885 };
1886 let repo_path = match self.tab().repo_path.clone() {
1887 Some(p) => p,
1888 None => return,
1889 };
1890 let label = action.label().to_string();
1891 let short = oid[..oid.len().min(7)].to_string();
1892 self.tab_mut().is_loading = true;
1893 self.tab_mut().status_message = Some(format!("{label} {short}…"));
1894 self.tab_mut().pending_commit_action_oid = None;
1895 self.tab_mut().pending_action_kind = None;
1896 self.tab_mut().action_input1.clear();
1897 let tx = self.bg_tx.clone();
1898 std::thread::spawn(move || {
1899 let workdir = repo_path.as_path();
1900 let res = action.execute(workdir, &oid);
1901 let _ = tx.send(crate::app::BackgroundResult::OperationDone {
1902 ok_message: res.as_ref().ok().map(|_| format!("{label} complete")),
1903 err_message: res.err().map(|e| e.to_string()),
1904 needs_refresh: true,
1905 needs_staging_refresh: false,
1906 });
1907 });
1908 }
1909
1910 pub fn open_file_history(&mut self, file_path: String) {
1912 let repo_path = match self.tab().repo_path.clone() {
1913 Some(p) => p,
1914 None => return,
1915 };
1916 let tab = self.tab_mut();
1917 tab.blame_path = None; tab.file_history_path = Some(file_path.clone());
1919 tab.file_history_commits.clear();
1920 tab.file_history_cursor = 0;
1921 tab.status_message = Some(format!(
1922 "Loading history for {}…",
1923 file_path.rsplit('/').next().unwrap_or(&file_path)
1924 ));
1925 let tx = self.bg_tx.clone();
1926 std::thread::spawn(move || {
1927 let repo = match gitkraft_core::features::repo::open_repo(&repo_path) {
1928 Ok(r) => r,
1929 Err(e) => {
1930 let _ = tx.send(BackgroundResult::OperationDone {
1931 ok_message: None,
1932 err_message: Some(format!("file history: {e}")),
1933 needs_refresh: false,
1934 needs_staging_refresh: false,
1935 });
1936 return;
1937 }
1938 };
1939 match gitkraft_core::file_history(&repo, &file_path, 500) {
1940 Ok(commits) => {
1941 let _ = tx.send(BackgroundResult::FileHistoryLoaded {
1942 path: file_path,
1943 commits,
1944 });
1945 }
1946 Err(e) => {
1947 let _ = tx.send(BackgroundResult::OperationDone {
1948 ok_message: None,
1949 err_message: Some(format!("file history: {e}")),
1950 needs_refresh: false,
1951 needs_staging_refresh: false,
1952 });
1953 }
1954 }
1955 });
1956 }
1957
1958 pub fn open_file_blame(&mut self, file_path: String) {
1960 let repo_path = match self.tab().repo_path.clone() {
1961 Some(p) => p,
1962 None => return,
1963 };
1964 let tab = self.tab_mut();
1965 tab.file_history_path = None; tab.blame_path = Some(file_path.clone());
1967 tab.blame_lines.clear();
1968 tab.blame_scroll = 0;
1969 tab.status_message = Some(format!(
1970 "Loading blame for {}…",
1971 file_path.rsplit('/').next().unwrap_or(&file_path)
1972 ));
1973 let tx = self.bg_tx.clone();
1974 std::thread::spawn(move || {
1975 let repo = match gitkraft_core::features::repo::open_repo(&repo_path) {
1976 Ok(r) => r,
1977 Err(e) => {
1978 let _ = tx.send(BackgroundResult::OperationDone {
1979 ok_message: None,
1980 err_message: Some(format!("blame: {e}")),
1981 needs_refresh: false,
1982 needs_staging_refresh: false,
1983 });
1984 return;
1985 }
1986 };
1987 match gitkraft_core::blame_file(&repo, &file_path) {
1988 Ok(lines) => {
1989 let _ = tx.send(BackgroundResult::FileBlameLoaded {
1990 path: file_path,
1991 lines,
1992 });
1993 }
1994 Err(e) => {
1995 let _ = tx.send(BackgroundResult::OperationDone {
1996 ok_message: None,
1997 err_message: Some(format!("blame: {e}")),
1998 needs_refresh: false,
1999 needs_staging_refresh: false,
2000 });
2001 }
2002 }
2003 });
2004 }
2005
2006 pub fn prompt_delete_file(&mut self, file_path: String) {
2008 let file_name = file_path
2009 .rsplit('/')
2010 .next()
2011 .unwrap_or(&file_path)
2012 .to_string();
2013 self.tab_mut().confirm_delete_file = Some(file_path);
2014 self.tab_mut().status_message = Some(format!(
2015 "Delete '{file_name}'? Press 'd' again to confirm, any other key to cancel"
2016 ));
2017 }
2018
2019 pub fn confirm_delete_file(&mut self) {
2021 let path = match self.tab().confirm_delete_file.clone() {
2022 Some(p) => p,
2023 None => return,
2024 };
2025 let repo_path = match self.tab().repo_path.clone() {
2026 Some(p) => p,
2027 None => return,
2028 };
2029 self.tab_mut().confirm_delete_file = None;
2030 self.tab_mut().is_loading = true;
2031 let file_name = path.rsplit('/').next().unwrap_or(&path).to_string();
2032 self.tab_mut().status_message = Some(format!("Deleting '{file_name}'…"));
2033 let tx = self.bg_tx.clone();
2034 std::thread::spawn(move || {
2035 let res = gitkraft_core::delete_file(repo_path.as_path(), &path);
2036 let _ = tx.send(BackgroundResult::OperationDone {
2037 ok_message: res.as_ref().ok().map(|_| format!("Deleted '{file_name}'")),
2038 err_message: res.err().map(|e| e.to_string()),
2039 needs_refresh: true,
2040 needs_staging_refresh: false,
2041 });
2042 });
2043 }
2044
2045 pub fn revert_selected_commit(&mut self) {
2046 let repo_path = match self.tab().repo_path.clone() {
2047 Some(p) => p,
2048 None => return,
2049 };
2050 let oid = match self.tab().commit_list_state.selected() {
2051 Some(idx) => match self.tab().commits.get(idx) {
2052 Some(c) => c.oid.clone(),
2053 None => return,
2054 },
2055 None => return,
2056 };
2057 self.tab_mut().is_loading = true;
2058 self.tab_mut().status_message = Some("Reverting commit…".into());
2059 let tx = self.bg_tx.clone();
2060 std::thread::spawn(move || {
2061 let workdir = std::path::Path::new(&repo_path);
2062 let res = gitkraft_core::features::repo::revert_commit(workdir, &oid);
2063 let _ = tx.send(BackgroundResult::OperationDone {
2064 ok_message: res.as_ref().ok().map(|_| format!("Reverted {}", &oid[..7])),
2065 err_message: res.err().map(|e| format!("revert: {e}")),
2066 needs_refresh: true,
2067 needs_staging_refresh: false,
2068 });
2069 });
2070 }
2071
2072 pub fn cherry_pick_selected(&mut self) {
2078 let repo_path = match self.tab().repo_path.clone() {
2079 Some(p) => p,
2080 None => return,
2081 };
2082
2083 let oids: Vec<String> = if self.tab().selected_commits.len() > 1 {
2085 let mut sorted = self.tab().selected_commits.clone();
2088 sorted.sort_unstable_by(|a, b| b.cmp(a)); sorted
2090 .iter()
2091 .filter_map(|&i| self.tab().commits.get(i).map(|c| c.oid.clone()))
2092 .collect()
2093 } else {
2094 match self.tab().commit_list_state.selected() {
2095 Some(i) => self
2096 .tab()
2097 .commits
2098 .get(i)
2099 .map(|c| vec![c.oid.clone()])
2100 .unwrap_or_default(),
2101 None => return,
2102 }
2103 };
2104
2105 if oids.is_empty() {
2106 return;
2107 }
2108
2109 let count = oids.len();
2110 let short = oids[0][..oids[0].len().min(7)].to_string();
2111 let label = if count == 1 {
2112 format!("Cherry-picking {short}…")
2113 } else {
2114 format!("Cherry-picking {count} commits…")
2115 };
2116
2117 let tab = self.tab_mut();
2118 tab.is_loading = true;
2119 tab.status_message = Some(label);
2120
2121 let tx = self.bg_tx.clone();
2122 std::thread::spawn(move || {
2123 let res: Result<String, String> = (|| {
2124 for oid in &oids {
2125 gitkraft_core::features::repo::cherry_pick_commit(&repo_path, oid)
2126 .map_err(|e| format!("cherry-pick {}: {e}", &oid[..oid.len().min(7)]))?;
2127 }
2128 Ok(format!("Cherry-picked {} commit(s)", oids.len()))
2129 })();
2130 let _ = tx.send(BackgroundResult::OperationDone {
2131 ok_message: res.as_ref().ok().cloned(),
2132 err_message: res.err(),
2133 needs_refresh: true,
2134 needs_staging_refresh: false,
2135 });
2136 });
2137 }
2138
2139 pub fn reset_to_selected_commit(&mut self, mode: &str) {
2140 let repo_path = match self.tab().repo_path.clone() {
2141 Some(p) => p,
2142 None => return,
2143 };
2144 let oid = match self.tab().commit_list_state.selected() {
2145 Some(idx) => match self.tab().commits.get(idx) {
2146 Some(c) => c.oid.clone(),
2147 None => return,
2148 },
2149 None => return,
2150 };
2151 let mode_owned = mode.to_string();
2152 self.tab_mut().is_loading = true;
2153 self.tab_mut().status_message = Some(format!("Resetting ({mode})…"));
2154 let tx = self.bg_tx.clone();
2155 std::thread::spawn(move || {
2156 let workdir = std::path::Path::new(&repo_path);
2157 let res = gitkraft_core::features::repo::reset_to_commit(workdir, &oid, &mode_owned);
2158 let _ = tx.send(BackgroundResult::OperationDone {
2159 ok_message: res
2160 .as_ref()
2161 .ok()
2162 .map(|_| format!("Reset ({mode_owned}) to {}", &oid[..7])),
2163 err_message: res.err().map(|e| format!("reset: {e}")),
2164 needs_refresh: true,
2165 needs_staging_refresh: false,
2166 });
2167 });
2168 }
2169
2170 fn unstaged_file_path(&self, idx: usize) -> String {
2173 if idx >= self.tab().unstaged_changes.len() {
2174 return String::new();
2175 }
2176 self.tab().unstaged_changes[idx].display_path().to_owned()
2177 }
2178
2179 fn staged_file_path(&self, idx: usize) -> String {
2180 if idx >= self.tab().staged_changes.len() {
2181 return String::new();
2182 }
2183 self.tab().staged_changes[idx].display_path().to_owned()
2184 }
2185}
2186
2187fn open_repo_str(path: &std::path::Path) -> Result<git2::Repository, String> {
2191 gitkraft_core::features::repo::open_repo(path).map_err(|e| e.to_string())
2192}
2193fn theme_name_to_index(name: &str) -> usize {
2195 gitkraft_core::theme_index_by_name(name)
2196}
2197
2198fn clamp_list_state(state: &mut ListState, len: usize) {
2200 if len == 0 {
2201 state.select(None);
2202 } else if state.selected().is_none() {
2203 state.select(Some(0));
2204 } else if let Some(i) = state.selected() {
2205 if i >= len {
2206 state.select(Some(len - 1));
2207 }
2208 }
2209}
2210
2211fn load_repo_blocking(path: &std::path::Path) -> Result<RepoPayload, String> {
2214 gitkraft_core::load_repo_snapshot(path).map_err(|e| e.to_string())
2215}
2216
2217#[cfg(test)]
2218mod tests {
2219 use super::*;
2220
2221 #[test]
2222 fn new_app_defaults() {
2223 let app = App::new();
2224 assert!(!app.should_quit);
2225 assert_eq!(app.screen, AppScreen::Welcome);
2226 assert_eq!(app.input_mode, InputMode::Normal);
2227 assert!(app.tab().commits.is_empty());
2228 assert!(app.tab().branches.is_empty());
2229 assert!(app.tab().repo_path.is_none());
2230 assert_eq!(app.tabs.len(), 1);
2231 assert_eq!(app.active_tab_index, 0);
2232 }
2233
2234 #[test]
2235 fn cycle_theme_next_wraps() {
2236 let mut app = App::new();
2237 app.current_theme_index = 0;
2238 app.cycle_theme_next();
2239 assert_eq!(app.current_theme_index, 1);
2240 for _ in 0..(gitkraft_core::THEME_COUNT - 1) {
2242 app.cycle_theme_next();
2243 }
2244 assert_eq!(app.current_theme_index, 0); }
2246
2247 #[test]
2248 fn cycle_theme_prev_wraps() {
2249 let mut app = App::new();
2250 app.current_theme_index = 0;
2251 app.cycle_theme_prev();
2252 assert_eq!(app.current_theme_index, gitkraft_core::THEME_COUNT - 1); }
2254
2255 #[test]
2256 fn theme_returns_struct() {
2257 let mut app = App::new();
2258 app.current_theme_index = 0;
2259 let theme = app.theme();
2260 assert_eq!(
2262 format!("{:?}", theme.border_active),
2263 format!("{:?}", ratatui::style::Color::Rgb(88, 166, 255))
2264 );
2265 }
2266
2267 #[test]
2268 fn theme_name_to_index_known() {
2269 assert_eq!(theme_name_to_index("Default"), 0);
2270 assert_eq!(theme_name_to_index("Dracula"), 8);
2271 assert_eq!(theme_name_to_index("Nord"), 9);
2272 }
2273
2274 #[test]
2275 fn theme_name_to_index_unknown_returns_zero() {
2276 assert_eq!(theme_name_to_index("NonExistentTheme"), 0);
2277 assert_eq!(theme_name_to_index(""), 0);
2278 }
2279
2280 #[test]
2281 fn tab_management_new_tab() {
2282 let mut app = App::new();
2283 assert_eq!(app.tabs.len(), 1);
2284 assert_eq!(app.active_tab_index, 0);
2285
2286 app.new_tab();
2287 assert_eq!(app.tabs.len(), 2);
2288 assert_eq!(app.active_tab_index, 1);
2289
2290 app.new_tab();
2291 assert_eq!(app.tabs.len(), 3);
2292 assert_eq!(app.active_tab_index, 2);
2293 }
2294
2295 #[test]
2296 fn tab_management_close_tab() {
2297 let mut app = App::new();
2298 app.new_tab();
2299 app.new_tab();
2300 assert_eq!(app.tabs.len(), 3);
2301 assert_eq!(app.active_tab_index, 2);
2302
2303 app.close_tab();
2304 assert_eq!(app.tabs.len(), 2);
2305 assert_eq!(app.active_tab_index, 1);
2306
2307 app.close_tab();
2308 assert_eq!(app.tabs.len(), 1);
2309 assert_eq!(app.active_tab_index, 0);
2310
2311 app.close_tab();
2313 assert_eq!(app.tabs.len(), 1);
2314 assert_eq!(app.active_tab_index, 0);
2315 }
2316
2317 #[test]
2318 fn tab_management_next_prev() {
2319 let mut app = App::new();
2320 app.new_tab();
2321 app.new_tab();
2322 app.next_tab();
2325 assert_eq!(app.active_tab_index, 0); app.next_tab();
2328 assert_eq!(app.active_tab_index, 1);
2329
2330 app.prev_tab();
2331 assert_eq!(app.active_tab_index, 0);
2332
2333 app.prev_tab();
2334 assert_eq!(app.active_tab_index, 2); }
2336
2337 #[test]
2338 fn repo_tab_display_name() {
2339 let tab = RepoTab::new();
2340 assert_eq!(tab.display_name(), "New Tab");
2341
2342 let mut tab2 = RepoTab::new();
2343 tab2.repo_path = Some(PathBuf::from("/home/user/projects/my-repo"));
2344 assert_eq!(tab2.display_name(), "my-repo");
2345 }
2346
2347 #[test]
2348 fn repo_tab_search_defaults() {
2349 let tab = RepoTab::new();
2350 assert!(!tab.search_active);
2351 assert!(tab.search_query.is_empty());
2352 assert!(tab.search_results.is_empty());
2353 }
2354
2355 #[test]
2356 fn repo_tab_new_has_empty_state() {
2357 let tab = RepoTab::new();
2358 assert!(tab.repo_path.is_none());
2359 assert!(tab.commits.is_empty());
2360 assert!(tab.branches.is_empty());
2361 assert!(tab.unstaged_changes.is_empty());
2362 assert!(tab.staged_changes.is_empty());
2363 assert!(tab.stashes.is_empty());
2364 assert!(tab.remotes.is_empty());
2365 assert!(tab.commit_files.is_empty());
2366 assert!(tab.selected_commit_oid.is_none());
2367 assert!(!tab.is_loading);
2368 assert!(!tab.confirm_discard);
2369 assert_eq!(tab.diff_scroll, 0);
2370 assert_eq!(tab.commit_diff_file_index, 0);
2371 }
2372
2373 #[test]
2374 fn new_tab_switches_to_welcome() {
2375 let mut app = App::new();
2376 app.screen = AppScreen::Main;
2377 app.new_tab();
2378 assert_eq!(app.screen, AppScreen::Welcome);
2379 assert_eq!(app.active_tab_index, 1);
2380 }
2381
2382 #[test]
2383 fn close_tab_last_tab_resets() {
2384 let mut app = App::new();
2385 app.tab_mut().search_active = true;
2387 app.tab_mut().search_query = "test".into();
2388
2389 app.close_tab();
2390
2391 assert_eq!(app.tabs.len(), 1);
2393 assert!(!app.tab().search_active);
2394 assert!(app.tab().search_query.is_empty());
2395 }
2396
2397 #[test]
2398 fn close_tab_middle_adjusts_index() {
2399 let mut app = App::new();
2400 app.new_tab();
2401 app.new_tab();
2402 app.active_tab_index = 1; app.close_tab();
2406
2407 assert_eq!(app.tabs.len(), 2);
2408 assert_eq!(app.active_tab_index, 1); }
2410
2411 #[test]
2412 fn next_tab_single_tab_no_change() {
2413 let mut app = App::new();
2414 app.next_tab();
2415 assert_eq!(app.active_tab_index, 0);
2416 }
2417
2418 #[test]
2419 fn prev_tab_single_tab_no_change() {
2420 let mut app = App::new();
2421 app.prev_tab();
2422 assert_eq!(app.active_tab_index, 0);
2423 }
2424
2425 #[test]
2426 fn open_browser_sets_dir_browser_screen() {
2427 let mut app = App::new();
2428 app.screen = AppScreen::Main;
2429 app.open_browser(PathBuf::from("/tmp"));
2430 assert_eq!(app.screen, AppScreen::DirBrowser);
2431 assert_eq!(app.browser_return_screen, AppScreen::Main);
2432 }
2433
2434 #[test]
2435 fn repo_tab_selected_defaults_empty() {
2436 let tab = RepoTab::new();
2437 assert!(tab.selected_unstaged.is_empty());
2438 assert!(tab.selected_staged.is_empty());
2439 }
2440
2441 #[test]
2442 fn repo_tab_selected_toggle() {
2443 let mut tab = RepoTab::new();
2444 tab.selected_unstaged.insert(0);
2445 tab.selected_unstaged.insert(2);
2446 assert_eq!(tab.selected_unstaged.len(), 2);
2447 assert!(tab.selected_unstaged.contains(&0));
2448 tab.selected_unstaged.remove(&0);
2449 assert_eq!(tab.selected_unstaged.len(), 1);
2450 assert!(!tab.selected_unstaged.contains(&0));
2451 }
2452
2453 #[test]
2454 fn auto_refresh_field_exists() {
2455 let app = App::new();
2456 assert!(app.last_auto_refresh.elapsed() < std::time::Duration::from_secs(1));
2457 }
2458
2459 #[test]
2460 fn editor_defaults_from_settings() {
2461 let app = App::new();
2462 let _ = app.editor.display_name();
2464 }
2465
2466 #[test]
2467 fn pull_rebase_sets_loading() {
2468 let mut app = App::new();
2469 app.tabs[0].repo_path = Some(PathBuf::from("/tmp/fake-repo"));
2470 app.pull_rebase();
2471 assert!(app.tab().is_loading);
2472 assert_eq!(
2473 app.tab().status_message.as_deref(),
2474 Some("Pulling (rebase)…")
2475 );
2476 }
2477
2478 #[test]
2479 fn repo_tab_diff_sub_pane_defaults_to_file_list() {
2480 let tab = RepoTab::new();
2481 assert_eq!(tab.diff_sub_pane, DiffSubPane::FileList);
2482 }
2483
2484 #[test]
2485 fn repo_tab_selected_file_indices_defaults_empty() {
2486 let tab = RepoTab::new();
2487 assert!(tab.selected_file_indices.is_empty());
2488 }
2489
2490 #[test]
2491 fn repo_tab_commit_diffs_defaults_empty_hashmap() {
2492 let tab = RepoTab::new();
2493 assert!(tab.commit_diffs.is_empty());
2494 }
2495
2496 #[test]
2497 fn next_tab_restores_main_screen_for_tab_with_repo() {
2498 let mut app = App::new();
2499 app.tabs[0].repo_path = Some(PathBuf::from("/tmp/repo-a"));
2501 app.new_tab();
2503 assert_eq!(app.active_tab_index, 1);
2504 app.next_tab();
2506 assert_eq!(app.active_tab_index, 0);
2507 assert_eq!(app.screen, AppScreen::Main);
2508 }
2509
2510 #[test]
2511 fn prev_tab_restores_welcome_for_tab_without_repo() {
2512 let mut app = App::new();
2513 app.tabs[0].repo_path = Some(PathBuf::from("/tmp/repo-a"));
2514 app.new_tab(); app.active_tab_index = 0;
2517 app.screen = AppScreen::Main;
2518 app.prev_tab();
2520 assert_eq!(app.active_tab_index, 1);
2521 assert_eq!(app.screen, AppScreen::Welcome);
2522 }
2523
2524 #[test]
2525 fn next_diff_file_clears_multi_selection() {
2526 let mut app = App::new();
2527 app.tab_mut().commit_files = vec![
2528 gitkraft_core::DiffFileEntry {
2529 old_file: String::new(),
2530 new_file: "a.rs".to_string(),
2531 status: gitkraft_core::FileStatus::Modified,
2532 },
2533 gitkraft_core::DiffFileEntry {
2534 old_file: String::new(),
2535 new_file: "b.rs".to_string(),
2536 status: gitkraft_core::FileStatus::Modified,
2537 },
2538 gitkraft_core::DiffFileEntry {
2539 old_file: String::new(),
2540 new_file: "c.rs".to_string(),
2541 status: gitkraft_core::FileStatus::Modified,
2542 },
2543 ];
2544 app.tab_mut().commit_diff_file_index = 0;
2545 app.tab_mut().selected_file_indices.insert(0);
2547 app.tab_mut().selected_file_indices.insert(1);
2548 app.next_diff_file();
2549 assert_eq!(app.tab().selected_file_indices.len(), 1);
2551 assert_eq!(app.tab().commit_diff_file_index, 1);
2552 assert!(app.tab().selected_file_indices.contains(&1));
2553 }
2554
2555 #[test]
2556 fn prev_diff_file_clears_multi_selection() {
2557 let mut app = App::new();
2558 app.tab_mut().commit_files = vec![
2559 gitkraft_core::DiffFileEntry {
2560 old_file: String::new(),
2561 new_file: "a.rs".to_string(),
2562 status: gitkraft_core::FileStatus::Modified,
2563 },
2564 gitkraft_core::DiffFileEntry {
2565 old_file: String::new(),
2566 new_file: "b.rs".to_string(),
2567 status: gitkraft_core::FileStatus::Modified,
2568 },
2569 ];
2570 app.tab_mut().commit_diff_file_index = 1;
2571 app.tab_mut().selected_file_indices.insert(0);
2572 app.tab_mut().selected_file_indices.insert(1);
2573 app.prev_diff_file();
2574 assert_eq!(app.tab().selected_file_indices.len(), 1);
2575 assert_eq!(app.tab().commit_diff_file_index, 0);
2576 assert!(app.tab().selected_file_indices.contains(&0));
2577 }
2578
2579 #[test]
2580 fn load_diff_for_file_index_out_of_bounds_is_noop() {
2581 let mut app = App::new();
2582 app.load_diff_for_file_index(0);
2584 assert!(app.tab().commit_diffs.is_empty());
2585 }
2586
2587 #[test]
2588 fn load_diff_for_file_index_skips_if_already_cached() {
2589 let mut app = App::new();
2590 app.tab_mut().commit_files = vec![gitkraft_core::DiffFileEntry {
2591 old_file: String::new(),
2592 new_file: "a.rs".to_string(),
2593 status: gitkraft_core::FileStatus::Modified,
2594 }];
2595 app.tab_mut().commit_diffs.insert(
2597 0,
2598 DiffInfo {
2599 old_file: String::new(),
2600 new_file: "a.rs".to_string(),
2601 status: gitkraft_core::FileStatus::Modified,
2602 hunks: Vec::new(),
2603 },
2604 );
2605 app.load_diff_for_file_index(0);
2608 assert!(!app.tab().is_loading);
2609 }
2610
2611 #[test]
2612 fn push_branch_requires_head_branch() {
2613 let mut app = App::new();
2614 app.tabs[0].repo_path = Some(PathBuf::from("/tmp/fake-repo"));
2615 app.push_branch();
2617 assert!(app.tab().error_message.is_some());
2618 }
2619
2620 #[test]
2621 fn force_push_requires_head_branch() {
2622 let mut app = App::new();
2623 app.tabs[0].repo_path = Some(PathBuf::from("/tmp/fake-repo"));
2624 app.force_push_branch();
2625 assert!(app.tab().error_message.is_some());
2626 }
2627
2628 #[test]
2629 fn merge_selected_branch_no_selection() {
2630 let mut app = App::new();
2631 app.tabs[0].repo_path = Some(PathBuf::from("/tmp/fake-repo"));
2632 app.merge_selected_branch();
2634 assert!(!app.tab().is_loading);
2635 }
2636
2637 #[test]
2638 fn rebase_onto_selected_no_selection() {
2639 let mut app = App::new();
2640 app.tabs[0].repo_path = Some(PathBuf::from("/tmp/fake-repo"));
2641 app.rebase_onto_selected_branch();
2642 assert!(!app.tab().is_loading);
2643 }
2644
2645 #[test]
2646 fn revert_selected_commit_no_selection() {
2647 let mut app = App::new();
2648 app.tabs[0].repo_path = Some(PathBuf::from("/tmp/fake-repo"));
2649 app.revert_selected_commit();
2650 assert!(!app.tab().is_loading);
2651 }
2652
2653 #[test]
2654 fn reset_to_selected_commit_no_selection() {
2655 let mut app = App::new();
2656 app.tabs[0].repo_path = Some(PathBuf::from("/tmp/fake-repo"));
2657 app.reset_to_selected_commit("soft");
2658 assert!(!app.tab().is_loading);
2659 }
2660
2661 #[test]
2662 fn open_repo_creates_new_tab_when_current_has_repo() {
2663 let mut app = App::new();
2664 app.tabs[0].repo_path = Some(PathBuf::from("/tmp/repo1"));
2665 app.screen = AppScreen::Main;
2666 let initial_tabs = app.tabs.len();
2668 if app.tab().repo_path.is_some() {
2669 app.new_tab();
2670 }
2671 assert_eq!(app.tabs.len(), initial_tabs + 1);
2672 }
2673
2674 #[test]
2677 fn commit_action_items_defaults_empty() {
2678 let tab = RepoTab::new();
2679 assert!(tab.commit_action_items.is_empty());
2680 assert_eq!(tab.commit_action_cursor, 0);
2681 assert!(tab.pending_commit_action_oid.is_none());
2682 assert!(tab.pending_action_kind.is_none());
2683 assert!(tab.action_input1.is_empty());
2684 }
2685
2686 #[test]
2687 fn open_commit_action_popup_no_selection_is_noop() {
2688 let mut app = App::new();
2689 app.open_commit_action_popup();
2691 assert!(app.tab().pending_commit_action_oid.is_none());
2692 assert!(app.tab().commit_action_items.is_empty());
2693 }
2694
2695 #[test]
2696 fn open_commit_action_popup_fills_items_from_menu_groups() {
2697 let mut app = App::new();
2698 app.tab_mut().commits = vec![gitkraft_core::CommitInfo {
2700 oid: "abc1234567890".to_string(),
2701 short_oid: "abc1234".to_string(),
2702 summary: "test commit".to_string(),
2703 message: "test commit".to_string(),
2704 author_name: "author".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 let expected: Vec<gitkraft_core::CommitActionKind> = gitkraft_core::COMMIT_MENU_GROUPS
2715 .iter()
2716 .flat_map(|g| g.iter().copied())
2717 .collect();
2718 assert_eq!(app.tab().commit_action_items, expected);
2719 assert_eq!(app.tab().commit_action_items.len(), 10);
2720 }
2721
2722 #[test]
2723 fn open_commit_action_popup_sets_pending_oid() {
2724 let mut app = App::new();
2725 app.tab_mut().commits = vec![gitkraft_core::CommitInfo {
2726 oid: "deadbeef1234567".to_string(),
2727 short_oid: "deadbee".to_string(),
2728 summary: "s".to_string(),
2729 message: "s".to_string(),
2730 author_name: "a".to_string(),
2731 author_email: "a@b.com".to_string(),
2732 time: Default::default(),
2733 parent_ids: vec![],
2734 }];
2735 app.tab_mut().commit_list_state.select(Some(0));
2736
2737 app.open_commit_action_popup();
2738
2739 assert_eq!(
2740 app.tab().pending_commit_action_oid.as_deref(),
2741 Some("deadbeef1234567")
2742 );
2743 assert_eq!(app.tab().commit_action_cursor, 0);
2744 }
2745
2746 #[test]
2747 fn open_commit_action_popup_resets_cursor() {
2748 let mut app = App::new();
2749 app.tab_mut().commits = vec![gitkraft_core::CommitInfo {
2750 oid: "aaa".to_string(),
2751 short_oid: "aaa".to_string(),
2752 summary: "s".to_string(),
2753 message: "s".to_string(),
2754 author_name: "a".to_string(),
2755 author_email: "a@b.com".to_string(),
2756 time: Default::default(),
2757 parent_ids: vec![],
2758 }];
2759 app.tab_mut().commit_list_state.select(Some(0));
2760 app.tab_mut().commit_action_cursor = 5;
2762
2763 app.open_commit_action_popup();
2764
2765 assert_eq!(app.tab().commit_action_cursor, 0);
2766 }
2767
2768 #[test]
2769 fn execute_commit_action_no_pending_oid_is_noop() {
2770 let mut app = App::new();
2771 app.execute_commit_action(gitkraft_core::CommitAction::CherryPick);
2773 assert!(!app.tab().is_loading);
2774 }
2775
2776 #[test]
2777 fn execute_commit_action_no_repo_path_is_noop() {
2778 let mut app = App::new();
2779 app.tab_mut().pending_commit_action_oid = Some("abc123".to_string());
2780 app.execute_commit_action(gitkraft_core::CommitAction::CherryPick);
2782 assert!(!app.tab().is_loading);
2783 }
2784
2785 #[test]
2786 fn execute_commit_action_sets_loading_and_clears_state() {
2787 let mut app = App::new();
2788 app.tab_mut().pending_commit_action_oid = Some("abc123".to_string());
2789 app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2790 app.tab_mut().pending_action_kind = Some(gitkraft_core::CommitActionKind::CherryPick);
2791 app.tab_mut().action_input1 = "some-input".to_string();
2792
2793 app.execute_commit_action(gitkraft_core::CommitAction::CherryPick);
2794
2795 assert!(app.tab().is_loading);
2796 assert!(app.tab().pending_commit_action_oid.is_none());
2798 assert!(app.tab().pending_action_kind.is_none());
2799 assert!(app.tab().action_input1.is_empty());
2800 }
2801
2802 #[test]
2805 fn file_history_defaults_empty() {
2806 let tab = RepoTab::new();
2807 assert!(tab.file_history_path.is_none());
2808 assert!(tab.file_history_commits.is_empty());
2809 assert_eq!(tab.file_history_cursor, 0);
2810 }
2811
2812 #[test]
2813 fn blame_defaults_empty() {
2814 let tab = RepoTab::new();
2815 assert!(tab.blame_path.is_none());
2816 assert!(tab.blame_lines.is_empty());
2817 assert_eq!(tab.blame_scroll, 0);
2818 }
2819
2820 #[test]
2821 fn confirm_delete_defaults_none() {
2822 let tab = RepoTab::new();
2823 assert!(tab.confirm_delete_file.is_none());
2824 }
2825
2826 #[test]
2827 fn open_file_history_no_repo_is_noop() {
2828 let mut app = App::new();
2829 app.open_file_history("src/main.rs".to_string());
2831 assert!(app.tab().file_history_path.is_none());
2832 }
2833
2834 #[test]
2835 fn open_file_history_sets_path_and_clears_blame() {
2836 let mut app = App::new();
2837 app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2838 app.tab_mut().blame_path = Some("old.rs".to_string());
2839
2840 app.open_file_history("src/main.rs".to_string());
2841
2842 assert_eq!(app.tab().file_history_path.as_deref(), Some("src/main.rs"));
2843 assert!(app.tab().blame_path.is_none());
2845 }
2846
2847 #[test]
2848 fn open_file_blame_sets_path_and_clears_history() {
2849 let mut app = App::new();
2850 app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2851 app.tab_mut().file_history_path = Some("old.rs".to_string());
2852
2853 app.open_file_blame("src/lib.rs".to_string());
2854
2855 assert_eq!(app.tab().blame_path.as_deref(), Some("src/lib.rs"));
2856 assert!(app.tab().file_history_path.is_none());
2858 }
2859
2860 #[test]
2861 fn prompt_delete_file_sets_confirm_and_status() {
2862 let mut app = App::new();
2863 app.prompt_delete_file("src/old.rs".to_string());
2864 assert_eq!(app.tab().confirm_delete_file.as_deref(), Some("src/old.rs"));
2865 assert!(app.tab().status_message.is_some());
2866 }
2867
2868 #[test]
2869 fn confirm_delete_file_no_repo_is_noop() {
2870 let mut app = App::new();
2871 app.tab_mut().confirm_delete_file = Some("src/old.rs".to_string());
2872 app.confirm_delete_file();
2874 assert!(!app.tab().is_loading);
2875 }
2876
2877 #[test]
2880 fn open_commit_files_in_editor_shows_path_when_no_editor() {
2881 let mut app = App::new();
2882 app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/repo"));
2883 app.tab_mut().commit_files = vec![gitkraft_core::DiffFileEntry {
2884 old_file: String::new(),
2885 new_file: "src/main.rs".to_string(),
2886 status: gitkraft_core::FileStatus::Modified,
2887 }];
2888 app.tab_mut().commit_diff_file_index = 0;
2889 app.open_commit_files_in_editor();
2892
2893 assert!(app.tab().status_message.is_some());
2895 let msg = app.tab().status_message.as_deref().unwrap();
2896 assert!(msg.contains("no editor") || msg.contains("src/main.rs"));
2897 }
2898
2899 #[test]
2900 fn open_commit_files_in_editor_queues_multi_file_for_terminal_editor() {
2901 let mut app = App::new();
2902 app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/repo"));
2903 app.tab_mut().commit_files = vec![
2904 gitkraft_core::DiffFileEntry {
2905 old_file: String::new(),
2906 new_file: "a.rs".to_string(),
2907 status: gitkraft_core::FileStatus::Modified,
2908 },
2909 gitkraft_core::DiffFileEntry {
2910 old_file: String::new(),
2911 new_file: "b.rs".to_string(),
2912 status: gitkraft_core::FileStatus::Modified,
2913 },
2914 ];
2915 app.tab_mut().selected_file_indices.insert(0);
2916 app.tab_mut().selected_file_indices.insert(1);
2917 app.editor = gitkraft_core::Editor::Helix; app.open_commit_files_in_editor();
2920
2921 let queued = app.pending_editor_open.as_ref().unwrap();
2923 assert_eq!(queued.len(), 2);
2924 }
2925
2926 #[test]
2927 fn cherry_pick_selected_single_commit_sets_loading() {
2928 let mut app = App::new();
2929 app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2930 app.tab_mut().commits = vec![gitkraft_core::CommitInfo {
2931 oid: "abc1234567890".to_string(),
2932 short_oid: "abc1234".to_string(),
2933 summary: "test".into(),
2934 message: "test".into(),
2935 author_name: "A".into(),
2936 author_email: "a@a.com".into(),
2937 time: Default::default(),
2938 parent_ids: Vec::new(),
2939 }];
2940 app.tab_mut().commit_list_state.select(Some(0));
2941
2942 app.cherry_pick_selected();
2943
2944 assert!(app.tab().is_loading);
2945 }
2946
2947 #[test]
2948 fn cherry_pick_selected_no_repo_path_is_noop() {
2949 let mut app = App::new();
2950 app.tab_mut().commits = vec![gitkraft_core::CommitInfo {
2951 oid: "abc1234567890".to_string(),
2952 short_oid: "abc1234".to_string(),
2953 summary: "test".into(),
2954 message: "test".into(),
2955 author_name: "A".into(),
2956 author_email: "a@a.com".into(),
2957 time: Default::default(),
2958 parent_ids: Vec::new(),
2959 }];
2960 app.tab_mut().commit_list_state.select(Some(0));
2961
2962 app.cherry_pick_selected();
2963
2964 assert!(!app.tab().is_loading);
2965 }
2966
2967 #[test]
2968 fn cherry_pick_selected_no_cursor_is_noop() {
2969 let mut app = App::new();
2970 app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2971 app.tab_mut().commits = vec![gitkraft_core::CommitInfo {
2972 oid: "abc1234567890".to_string(),
2973 short_oid: "abc1234".to_string(),
2974 summary: "test".into(),
2975 message: "test".into(),
2976 author_name: "A".into(),
2977 author_email: "a@a.com".into(),
2978 time: Default::default(),
2979 parent_ids: Vec::new(),
2980 }];
2981 app.cherry_pick_selected();
2984
2985 assert!(
2986 !app.tab().is_loading,
2987 "no cursor → cherry_pick_selected must be a noop"
2988 );
2989 }
2990
2991 #[test]
2992 fn cherry_pick_selected_single_sets_status_message_with_short_oid() {
2993 let mut app = App::new();
2994 app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2995 app.tab_mut().commits = vec![gitkraft_core::CommitInfo {
2996 oid: "deadbeef12345".to_string(),
2997 short_oid: "deadbee".to_string(),
2998 summary: "fix: something".into(),
2999 message: "fix: something".into(),
3000 author_name: "A".into(),
3001 author_email: "a@a.com".into(),
3002 time: Default::default(),
3003 parent_ids: Vec::new(),
3004 }];
3005 app.tab_mut().commit_list_state.select(Some(0));
3006
3007 app.cherry_pick_selected();
3008
3009 let msg = app.tab().status_message.as_deref().unwrap_or("");
3010 assert!(
3011 msg.contains("deadbee"),
3012 "status message must contain the short OID; got: {msg}"
3013 );
3014 assert!(
3015 msg.to_lowercase().contains("cherry"),
3016 "status message must mention cherry-pick; got: {msg}"
3017 );
3018 }
3019
3020 #[test]
3021 fn cherry_pick_selected_multi_uses_selected_commits_and_sets_count_message() {
3022 let mut app = App::new();
3023 app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
3024 app.tab_mut().commits = vec![
3026 gitkraft_core::CommitInfo {
3027 oid: "oid_newest".to_string(),
3028 short_oid: "newest".to_string(),
3029 summary: "newest".into(),
3030 message: "newest".into(),
3031 author_name: "A".into(),
3032 author_email: "a@a.com".into(),
3033 time: Default::default(),
3034 parent_ids: Vec::new(),
3035 },
3036 gitkraft_core::CommitInfo {
3037 oid: "oid_middle".to_string(),
3038 short_oid: "middle".to_string(),
3039 summary: "middle".into(),
3040 message: "middle".into(),
3041 author_name: "A".into(),
3042 author_email: "a@a.com".into(),
3043 time: Default::default(),
3044 parent_ids: Vec::new(),
3045 },
3046 gitkraft_core::CommitInfo {
3047 oid: "oid_oldest".to_string(),
3048 short_oid: "oldest".to_string(),
3049 summary: "oldest".into(),
3050 message: "oldest".into(),
3051 author_name: "A".into(),
3052 author_email: "a@a.com".into(),
3053 time: Default::default(),
3054 parent_ids: Vec::new(),
3055 },
3056 ];
3057 app.tab_mut().selected_commits = vec![0, 1, 2];
3059 app.tab_mut().commit_list_state.select(Some(0));
3060
3061 app.cherry_pick_selected();
3062
3063 assert!(
3064 app.tab().is_loading,
3065 "multi cherry-pick must set is_loading"
3066 );
3067 let msg = app.tab().status_message.as_deref().unwrap_or("");
3068 assert!(
3069 msg.contains("3"),
3070 "status message must mention commit count (3); got: {msg}"
3071 );
3072 }
3073
3074 #[test]
3075 fn cherry_pick_selected_multi_orders_oldest_first() {
3076 let mut app = App::new();
3080 app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
3081 app.tab_mut().commits = vec![
3082 gitkraft_core::CommitInfo {
3083 oid: "oid_0_newest".to_string(),
3084 short_oid: "oid0new".to_string(),
3085 summary: "newest".into(),
3086 message: "newest".into(),
3087 author_name: "A".into(),
3088 author_email: "a@a.com".into(),
3089 time: Default::default(),
3090 parent_ids: Vec::new(),
3091 },
3092 gitkraft_core::CommitInfo {
3093 oid: "oid_1_oldest".to_string(),
3094 short_oid: "oid1old".to_string(),
3095 summary: "oldest".into(),
3096 message: "oldest".into(),
3097 author_name: "A".into(),
3098 author_email: "a@a.com".into(),
3099 time: Default::default(),
3100 parent_ids: Vec::new(),
3101 },
3102 ];
3103 app.tab_mut().selected_commits = vec![0, 1];
3104
3105 app.cherry_pick_selected();
3106
3107 let msg = app.tab().status_message.as_deref().unwrap_or("");
3110 assert!(
3111 msg.contains("2"),
3112 "multi cherry-pick message should say 2 commits; got: {msg}"
3113 );
3114 }
3115
3116 #[test]
3117 fn open_commit_files_in_editor_uses_single_cursor_when_no_multi_selection() {
3118 let mut app = App::new();
3119 app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/repo"));
3120 app.tab_mut().commit_files = vec![
3121 gitkraft_core::DiffFileEntry {
3122 old_file: String::new(),
3123 new_file: "a.rs".to_string(),
3124 status: gitkraft_core::FileStatus::Modified,
3125 },
3126 gitkraft_core::DiffFileEntry {
3127 old_file: String::new(),
3128 new_file: "b.rs".to_string(),
3129 status: gitkraft_core::FileStatus::Modified,
3130 },
3131 ];
3132 app.tab_mut().commit_diff_file_index = 1; app.editor = gitkraft_core::Editor::Helix;
3134 app.open_commit_files_in_editor();
3137
3138 let queued = app.pending_editor_open.as_ref().unwrap();
3139 assert_eq!(queued.len(), 1);
3140 assert!(queued[0].ends_with("b.rs"));
3141 }
3142
3143 #[test]
3144 fn git_state_changed_triggers_full_refresh_when_repo_open() {
3145 let mut app = App::new();
3146 app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
3147 app.bg_tx
3150 .send(crate::app::BackgroundResult::GitStateChanged)
3151 .unwrap();
3152 app.poll_background();
3153 assert!(
3156 app.tab().is_loading,
3157 "GitStateChanged must trigger a full refresh (is_loading should be true)"
3158 );
3159 }
3160
3161 #[test]
3162 fn git_state_changed_is_skipped_when_loading_no_infinite_loop() {
3163 let mut app = App::new();
3164 app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
3165 app.tab_mut().is_loading = true; app.bg_tx
3169 .send(crate::app::BackgroundResult::GitStateChanged)
3170 .unwrap();
3171 app.bg_tx
3172 .send(crate::app::BackgroundResult::GitStateChanged)
3173 .unwrap();
3174 app.poll_background();
3175
3176 assert!(app.tab().is_loading); }
3181
3182 #[test]
3183 fn git_state_changed_is_noop_when_already_loading() {
3184 let mut app = App::new();
3185 app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
3186 app.tab_mut().is_loading = true; app.bg_tx
3188 .send(crate::app::BackgroundResult::GitStateChanged)
3189 .unwrap();
3190 app.poll_background();
3191 assert_ne!(
3195 app.tab().status_message.as_deref(),
3196 Some("Refreshing\u{2026}"),
3197 "GitStateChanged must be a noop when already loading"
3198 );
3199 }
3200
3201 #[test]
3202 fn watcher_handles_periodic_refresh_last_auto_refresh_field_retained() {
3203 let app = App::new();
3208 assert!(app.last_auto_refresh.elapsed() < std::time::Duration::from_secs(1));
3210 }
3211
3212 #[test]
3213 fn git_state_changed_respects_2s_cooldown_after_refresh() {
3214 let mut app = App::new();
3215 app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
3216 app.last_refresh_completed = std::time::Instant::now();
3218
3219 app.bg_tx
3220 .send(crate::app::BackgroundResult::GitStateChanged)
3221 .unwrap();
3222 app.poll_background();
3223
3224 assert!(
3226 !app.tab().is_loading,
3227 "GitStateChanged within 2s of last refresh must be skipped (cooldown)"
3228 );
3229 }
3230
3231 #[test]
3232 fn git_state_changed_fires_after_cooldown_expires() {
3233 let mut app = App::new();
3234 app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
3235 app.last_refresh_completed = std::time::Instant::now() - std::time::Duration::from_secs(5);
3237
3238 app.bg_tx
3239 .send(crate::app::BackgroundResult::GitStateChanged)
3240 .unwrap();
3241 app.poll_background();
3242
3243 assert!(
3245 app.tab().is_loading,
3246 "GitStateChanged after 2s cooldown must trigger a silent refresh"
3247 );
3248 }
3249
3250 #[test]
3251 fn refresh_silent_sets_loading_but_no_status_message() {
3252 let mut app = App::new();
3253 app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
3254 app.tab_mut().status_message = Some("3 files selected".into());
3256
3257 app.refresh_silent();
3258
3259 assert!(app.tab().is_loading, "refresh_silent must set is_loading");
3260 assert_eq!(
3261 app.tab().status_message.as_deref(),
3262 Some("3 files selected"),
3263 "refresh_silent must NOT overwrite user status messages"
3264 );
3265 }
3266
3267 #[test]
3268 fn refresh_sets_refreshing_status_message() {
3269 let mut app = App::new();
3270 app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
3271
3272 app.refresh();
3273
3274 assert!(app.tab().is_loading);
3275 assert_eq!(
3276 app.tab().status_message.as_deref(),
3277 Some("Refreshing…"),
3278 "user-triggered refresh must show Refreshing… status"
3279 );
3280 }
3281
3282 #[test]
3283 fn repo_loaded_stamps_last_refresh_completed() {
3284 let mut app = App::new();
3285 let old_ts = app.last_refresh_completed;
3286 app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake"));
3287
3288 let payload = gitkraft_core::RepoSnapshot {
3290 info: gitkraft_core::RepoInfo {
3291 path: std::path::PathBuf::from("/tmp/fake/.git"),
3292 workdir: Some(std::path::PathBuf::from("/tmp/fake")),
3293 head_branch: Some("main".to_string()),
3294 is_bare: false,
3295 state: gitkraft_core::RepoState::Clean,
3296 },
3297 branches: vec![],
3298 commits: vec![],
3299 graph_rows: vec![],
3300 unstaged: vec![],
3301 staged: vec![],
3302 stashes: vec![],
3303 remotes: vec![],
3304 };
3305 app.bg_tx
3306 .send(crate::app::BackgroundResult::RepoLoaded {
3307 path: std::path::PathBuf::from("/tmp/fake"),
3308 result: Ok(payload),
3309 })
3310 .unwrap();
3311 app.poll_background();
3312
3313 assert!(
3314 app.last_refresh_completed > old_ts,
3315 "RepoLoaded must update last_refresh_completed"
3316 );
3317 }
3318
3319 #[test]
3322 fn selected_idx_returns_early_with_error_when_list_is_empty() {
3323 let mut app = App::new();
3326 app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
3327 app.stash_pop_selected();
3329 assert!(
3330 app.tab().error_message.is_some(),
3331 "selected_idx! must set error_message when list is empty"
3332 );
3333 assert!(!app.tab().is_loading, "must not start a background task");
3334 }
3335
3336 #[test]
3337 fn selected_idx_returns_early_with_error_when_selection_out_of_bounds() {
3338 let mut app = App::new();
3339 app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
3340 app.tab_mut().stashes = vec![gitkraft_core::StashEntry {
3342 index: 0,
3343 message: "WIP".to_string(),
3344 oid: "abc1234deadbeef".to_string(),
3345 }];
3346 app.tab_mut().stash_list_state.select(Some(5)); app.stash_pop_selected();
3348 assert!(
3349 app.tab().error_message.is_some(),
3350 "selected_idx! must set error_message when selection is out of bounds"
3351 );
3352 }
3353
3354 #[test]
3355 fn selected_idx_succeeds_when_selection_is_valid() {
3356 let mut app = App::new();
3359 app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
3360 app.tab_mut().stashes = vec![gitkraft_core::StashEntry {
3361 index: 0,
3362 message: "WIP".to_string(),
3363 oid: "abc1234deadbeef".to_string(),
3364 }];
3365 app.tab_mut().stash_list_state.select(Some(0));
3366 app.stash_pop_selected();
3367 assert!(app.tab().error_message.is_none());
3368 assert!(app.tab().is_loading, "operation must have started");
3369 }
3370
3371 #[test]
3374 fn require_selection_sets_status_message_when_nothing_selected() {
3375 let mut app = App::new();
3377 app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
3378 app.stage_selected();
3380 assert_eq!(
3381 app.tab().status_message.as_deref(),
3382 Some("No unstaged file selected"),
3383 "require_selection! must set status_message when nothing is selected"
3384 );
3385 assert!(!app.tab().is_loading);
3386 }
3387
3388 #[test]
3389 fn require_selection_proceeds_when_selection_exists() {
3390 let mut app = App::new();
3391 app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
3392 app.tab_mut().unstaged_changes = vec![gitkraft_core::DiffInfo {
3393 old_file: String::new(),
3394 new_file: "src/main.rs".to_string(),
3395 status: gitkraft_core::FileStatus::Modified,
3396 hunks: vec![],
3397 }];
3398 app.tab_mut().unstaged_list_state.select(Some(0));
3399 app.stage_selected();
3400 assert!(
3402 app.tab().is_loading,
3403 "stage_selected must start when file is selected"
3404 );
3405 assert!(app.tab().error_message.is_none());
3406 }
3407
3408 #[test]
3409 fn require_selection_unstage_sets_status_when_nothing_selected() {
3410 let mut app = App::new();
3411 app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
3412 app.unstage_selected();
3413 assert_eq!(
3414 app.tab().status_message.as_deref(),
3415 Some("No staged file selected"),
3416 "require_selection! must set status_message for unstage when nothing selected"
3417 );
3418 assert!(!app.tab().is_loading);
3419 }
3420
3421 #[test]
3422 fn require_selection_discard_sets_status_when_nothing_selected() {
3423 let mut app = App::new();
3424 app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
3425 app.discard_selected();
3426 assert_eq!(
3427 app.tab().status_message.as_deref(),
3428 Some("No unstaged file selected"),
3429 "require_selection! must set status_message for discard when nothing selected"
3430 );
3431 assert!(!app.tab().is_loading);
3432 }
3433
3434 #[test]
3437 fn bg_task_returns_early_when_no_repo_path() {
3438 let mut app = App::new();
3440 app.tab_mut().selected_commit_oid = Some("abc123".to_string());
3442 app.load_commit_diff_by_oid();
3443 assert!(
3444 !app.tab().is_loading,
3445 "bg_task! must return early when repo_path is None"
3446 );
3447 }
3448
3449 #[test]
3450 fn bg_task_sets_loading_and_status_when_repo_is_set() {
3451 let mut app = App::new();
3452 app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
3453 app.tab_mut().selected_commit_oid = Some("abc123deadbeef".to_string());
3454 app.load_commit_diff_by_oid();
3455 assert!(
3456 app.tab().is_loading,
3457 "bg_task! must set is_loading when repo_path is present"
3458 );
3459 }
3460
3461 #[test]
3464 fn bg_op_returns_early_when_no_repo_path() {
3465 let mut app = App::new();
3467 app.stage_all(); assert!(
3469 !app.tab().is_loading,
3470 "bg_op! must return early when repo_path is None"
3471 );
3472 }
3473
3474 #[test]
3475 fn bg_op_sets_loading_and_status_when_repo_is_set() {
3476 let mut app = App::new();
3477 app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
3478 app.stage_all();
3479 assert!(app.tab().is_loading, "bg_op! must set is_loading");
3480 assert!(
3481 app.tab().status_message.is_some(),
3482 "bg_op! must set status_message"
3483 );
3484 }
3485
3486 #[test]
3487 fn bg_op_refresh_variant_triggers_full_refresh_on_success() {
3488 let mut app = App::new();
3491 app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
3492 app.fetch_remote();
3493 assert!(app.tab().is_loading);
3494 }
3495
3496 #[test]
3499 fn input_mode_normal_does_not_set_mode_text_in_spans() {
3500 let app = App::new();
3504 assert_eq!(app.input_mode, crate::app::InputMode::Normal);
3505 assert!(app.tab().status_message.is_none());
3507 }
3508
3509 #[test]
3510 fn input_mode_input_shows_purpose_label() {
3511 let mut app = App::new();
3514 app.input_mode = crate::app::InputMode::Input;
3515 app.input_purpose = crate::app::InputPurpose::CommitMessage;
3516 app.tab_mut().status_message = Some("Enter commit message:".into());
3517 assert_eq!(
3518 app.tab().status_message.as_deref(),
3519 Some("Enter commit message:")
3520 );
3521 }
3522}