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
66#[derive(Debug)]
70pub struct RepoPayload {
71 pub info: RepoInfo,
72 pub branches: Vec<BranchInfo>,
73 pub commits: Vec<CommitInfo>,
74 pub graph_rows: Vec<gitkraft_core::GraphRow>,
75 pub unstaged: Vec<DiffInfo>,
76 pub staged: Vec<DiffInfo>,
77 pub stashes: Vec<StashEntry>,
78 pub remotes: Vec<RemoteInfo>,
79}
80
81#[derive(Debug)]
83pub enum BackgroundResult {
84 RepoLoaded {
87 path: PathBuf,
88 result: Result<RepoPayload, String>,
89 },
90 FetchDone(Result<(), String>),
92 CommitDiffLoaded(Result<Vec<DiffInfo>, String>),
94 StagingRefreshed(Result<StagingPayload, String>),
96 OperationDone {
99 ok_message: Option<String>,
100 err_message: Option<String>,
101 needs_refresh: bool,
103 needs_staging_refresh: bool,
105 },
106 CommitFileListLoaded(Result<Vec<gitkraft_core::DiffFileEntry>, String>),
108 SingleFileDiffLoaded(Result<gitkraft_core::DiffInfo, String>),
110 SearchResults(Result<Vec<gitkraft_core::CommitInfo>, String>),
112}
113
114#[derive(Debug)]
116pub struct StagingPayload {
117 pub unstaged: Vec<DiffInfo>,
118 pub staged: Vec<DiffInfo>,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
124pub enum AppScreen {
125 Welcome,
126 DirBrowser,
127 Main,
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum ActivePane {
132 Branches,
133 CommitLog,
134 DiffView,
135 Staging,
136}
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum InputMode {
140 Normal,
141 Input,
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub enum InputPurpose {
146 None,
147 CommitMessage,
148 BranchName,
149 RepoPath,
150 SearchQuery,
151 StashMessage,
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub enum StagingFocus {
157 Unstaged,
158 Staged,
159}
160
161pub struct RepoTab {
165 pub repo_path: Option<PathBuf>,
166 pub repo_info: Option<RepoInfo>,
167
168 pub branches: Vec<BranchInfo>,
169 pub branch_list_state: ListState,
170
171 pub commits: Vec<CommitInfo>,
172 pub graph_rows: Vec<gitkraft_core::GraphRow>,
173 pub commit_list_state: ListState,
174
175 pub unstaged_changes: Vec<DiffInfo>,
176 pub staged_changes: Vec<DiffInfo>,
177 pub unstaged_list_state: ListState,
178 pub staged_list_state: ListState,
179 pub staging_focus: StagingFocus,
180 pub selected_diff: Option<DiffInfo>,
181 pub diff_scroll: u16,
182 pub commit_diffs: Vec<DiffInfo>,
184 pub commit_diff_file_index: usize,
186 pub commit_files: Vec<gitkraft_core::DiffFileEntry>,
188 pub selected_commit_oid: Option<String>,
190
191 pub stashes: Vec<StashEntry>,
192 pub stash_list_state: ListState,
193 pub remotes: Vec<RemoteInfo>,
194
195 pub search_query: String,
197 pub search_active: bool,
199 pub search_results: Vec<CommitInfo>,
201
202 pub stash_message_buffer: String,
204
205 pub status_message: Option<String>,
206 pub error_message: Option<String>,
207
208 pub is_loading: bool,
210
211 pub confirm_discard: bool,
214
215 pub selected_unstaged: std::collections::HashSet<usize>,
217 pub selected_staged: std::collections::HashSet<usize>,
219}
220
221impl RepoTab {
222 #[must_use]
223 pub fn new() -> Self {
224 Self {
225 repo_path: None,
226 repo_info: None,
227
228 branches: Vec::new(),
229 branch_list_state: ListState::default(),
230
231 commits: Vec::new(),
232 graph_rows: Vec::new(),
233 commit_list_state: ListState::default(),
234
235 unstaged_changes: Vec::new(),
236 staged_changes: Vec::new(),
237 unstaged_list_state: ListState::default(),
238 staged_list_state: ListState::default(),
239 staging_focus: StagingFocus::Unstaged,
240 selected_diff: None,
241 diff_scroll: 0,
242 commit_diffs: Vec::new(),
243 commit_diff_file_index: 0,
244 commit_files: Vec::new(),
245 selected_commit_oid: None,
246
247 stashes: Vec::new(),
248 stash_list_state: ListState::default(),
249 remotes: Vec::new(),
250
251 stash_message_buffer: String::new(),
252
253 search_query: String::new(),
254 search_active: false,
255 search_results: Vec::new(),
256
257 status_message: None,
258 error_message: None,
259
260 is_loading: false,
261
262 confirm_discard: false,
263
264 selected_unstaged: std::collections::HashSet::new(),
265 selected_staged: std::collections::HashSet::new(),
266 }
267 }
268
269 pub fn display_name(&self) -> String {
272 match &self.repo_path {
273 Some(p) => p
274 .file_name()
275 .map(|n| n.to_string_lossy().into_owned())
276 .unwrap_or_else(|| "New Tab".into()),
277 None => "New Tab".into(),
278 }
279 }
280}
281
282impl Default for RepoTab {
283 fn default() -> Self {
284 Self::new()
285 }
286}
287
288pub struct App {
291 pub should_quit: bool,
292 pub screen: AppScreen,
293 pub active_pane: ActivePane,
294 pub input_mode: InputMode,
295 pub input_purpose: InputPurpose,
296 pub tick_count: u64,
297
298 pub bg_rx: mpsc::Receiver<BackgroundResult>,
300 pub(crate) bg_tx: mpsc::Sender<BackgroundResult>,
302
303 pub input_buffer: String,
304
305 pub show_theme_panel: bool,
307 pub show_options_panel: bool,
309 pub editor: gitkraft_core::Editor,
311 pub show_editor_panel: bool,
313 pub editor_list_state: ListState,
315 pub current_theme_index: usize,
317 pub theme_list_state: ListState,
319
320 pub recent_repos: Vec<gitkraft_core::RepoHistoryEntry>,
322
323 pub browser_dir: PathBuf,
325 pub browser_entries: Vec<std::path::PathBuf>,
327 pub browser_list_state: ListState,
329 pub browser_return_screen: AppScreen,
331
332 pub tabs: Vec<RepoTab>,
334 pub active_tab_index: usize,
336
337 pub last_auto_refresh: std::time::Instant,
339}
340
341impl App {
342 #[must_use]
345 pub fn new() -> Self {
346 let settings = gitkraft_core::features::persistence::load_settings().unwrap_or_default();
347
348 let theme_index = theme_name_to_index(settings.theme_name.as_deref().unwrap_or(""));
349
350 let recent_repos = settings.recent_repos;
351
352 let (bg_tx, bg_rx) = mpsc::channel();
353
354 Self {
355 should_quit: false,
356 screen: AppScreen::Welcome,
357 active_pane: ActivePane::Branches,
358 input_mode: InputMode::Normal,
359 input_purpose: InputPurpose::None,
360 tick_count: 0,
361
362 bg_rx,
363 bg_tx,
364
365 input_buffer: String::new(),
366
367 show_theme_panel: false,
368 show_options_panel: false,
369 editor: settings
370 .editor_name
371 .as_deref()
372 .map(|name| {
373 gitkraft_core::EDITOR_NAMES
374 .iter()
375 .position(|n| n.eq_ignore_ascii_case(name))
376 .map(gitkraft_core::Editor::from_index)
377 .unwrap_or_else(|| {
378 if name.eq_ignore_ascii_case("none") {
379 gitkraft_core::Editor::None
380 } else {
381 gitkraft_core::Editor::Custom(name.to_string())
382 }
383 })
384 })
385 .unwrap_or(gitkraft_core::Editor::None),
386 show_editor_panel: false,
387 editor_list_state: {
388 let mut s = ListState::default();
389 s.select(Some(0));
390 s
391 },
392 current_theme_index: theme_index,
393 theme_list_state: {
394 let mut s = ListState::default();
395 s.select(Some(theme_index));
396 s
397 },
398
399 recent_repos,
400
401 browser_dir: dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")),
402 browser_entries: Vec::new(),
403 browser_list_state: ListState::default(),
404 browser_return_screen: AppScreen::Welcome,
405
406 tabs: vec![RepoTab::new()],
407 active_tab_index: 0,
408
409 last_auto_refresh: std::time::Instant::now(),
410 }
411 }
412
413 #[inline]
417 pub fn tab(&self) -> &RepoTab {
418 &self.tabs[self.active_tab_index]
419 }
420
421 #[inline]
423 pub fn tab_mut(&mut self) -> &mut RepoTab {
424 &mut self.tabs[self.active_tab_index]
425 }
426
427 pub fn new_tab(&mut self) {
431 self.tabs.push(RepoTab::new());
432 self.active_tab_index = self.tabs.len() - 1;
433 self.screen = AppScreen::Welcome;
434 if let Ok(settings) = gitkraft_core::features::persistence::load_settings() {
436 self.recent_repos = settings.recent_repos;
437 }
438 self.save_session();
439 }
440
441 pub fn close_tab(&mut self) {
443 if self.tabs.len() <= 1 {
444 self.tabs[0] = RepoTab::new();
445 self.active_tab_index = 0;
446 } else {
447 self.tabs.remove(self.active_tab_index);
448 if self.active_tab_index >= self.tabs.len() {
449 self.active_tab_index = self.tabs.len() - 1;
450 }
451 }
452 self.save_session();
453 }
454
455 pub fn next_tab(&mut self) {
457 if !self.tabs.is_empty() {
458 self.active_tab_index = (self.active_tab_index + 1) % self.tabs.len();
459 }
460 }
461
462 pub fn prev_tab(&mut self) {
464 if !self.tabs.is_empty() {
465 if self.active_tab_index == 0 {
466 self.active_tab_index = self.tabs.len() - 1;
467 } else {
468 self.active_tab_index -= 1;
469 }
470 }
471 }
472}
473
474impl Default for App {
475 fn default() -> Self {
476 Self::new()
477 }
478}
479
480impl App {
481 pub fn cycle_theme_next(&mut self) {
484 let count = 27; self.current_theme_index = (self.current_theme_index + 1) % count;
486 self.theme_list_state.select(Some(self.current_theme_index));
487 self.tab_mut().status_message = Some(format!("Theme: {}", self.current_theme_name()));
488 }
489
490 pub fn cycle_theme_prev(&mut self) {
491 let count = 27;
492 if self.current_theme_index == 0 {
493 self.current_theme_index = count - 1;
494 } else {
495 self.current_theme_index -= 1;
496 }
497 self.theme_list_state.select(Some(self.current_theme_index));
498 self.tab_mut().status_message = Some(format!("Theme: {}", self.current_theme_name()));
499 }
500
501 pub fn current_theme_name(&self) -> &'static str {
502 gitkraft_core::THEME_NAMES
503 .get(self.current_theme_index)
504 .copied()
505 .unwrap_or("Default")
506 }
507
508 pub fn theme(&self) -> crate::features::theme::palette::UiTheme {
510 crate::features::theme::palette::theme_for_index(self.current_theme_index)
511 }
512
513 pub fn save_theme(&self) {
515 let _ = gitkraft_core::features::persistence::save_theme(self.current_theme_name());
516 }
517
518 pub fn save_session(&self) {
520 let paths: Vec<std::path::PathBuf> = self
521 .tabs
522 .iter()
523 .filter_map(|t| t.repo_path.clone())
524 .collect();
525 let active = self.active_tab_index;
526 let _ = gitkraft_core::features::persistence::save_session(&paths, active);
527 }
528
529 pub fn open_repo(&mut self, path: PathBuf) {
532 self.tab_mut().error_message = None;
533 self.tab_mut().status_message = Some("Opening repository…".into());
534 self.tab_mut().is_loading = true;
535 self.tab_mut().repo_path = Some(path.clone());
536 self.screen = AppScreen::Main;
537
538 let tx = self.bg_tx.clone();
539 std::thread::spawn(move || {
540 let result = load_repo_blocking(&path);
541 let _ = tx.send(BackgroundResult::RepoLoaded { path, result });
542 });
543 self.save_session();
544 }
545
546 pub fn refresh(&mut self) {
547 self.tab_mut().error_message = None;
548 self.tab_mut().is_loading = true;
549 self.tab_mut().status_message = Some("Refreshing…".into());
550
551 let path = match self.tab().repo_path.clone() {
552 Some(p) => p,
553 None => {
554 self.tab_mut().error_message = Some("No repository open".into());
555 self.tab_mut().is_loading = false;
556 return;
557 }
558 };
559
560 let tx = self.bg_tx.clone();
561 std::thread::spawn(move || {
562 let result = load_repo_blocking(&path);
563 let _ = tx.send(BackgroundResult::RepoLoaded { path, result });
564 });
565 }
566
567 pub fn poll_background(&mut self) {
570 while let Ok(result) = self.bg_rx.try_recv() {
571 match result {
572 BackgroundResult::RepoLoaded {
573 path: loaded_path,
574 result: res,
575 } => {
576 let tab_idx = self
578 .tabs
579 .iter()
580 .position(|t| t.repo_path.as_ref() == Some(&loaded_path))
581 .unwrap_or(self.active_tab_index);
582
583 self.tabs[tab_idx].is_loading = false;
584 match res {
585 Ok(payload) => {
586 let canonical = payload.info.workdir.clone().unwrap_or_else(|| {
587 self.tabs[tab_idx].repo_path.clone().unwrap_or_default()
588 });
589 self.tabs[tab_idx].repo_path = Some(canonical.clone());
590
591 let _ = gitkraft_core::features::persistence::record_repo_opened(
593 &canonical,
594 );
595 if let Ok(settings) =
596 gitkraft_core::features::persistence::load_settings()
597 {
598 self.recent_repos = settings.recent_repos;
599 }
600
601 let tab = &mut self.tabs[tab_idx];
602 tab.repo_info = Some(payload.info);
603 tab.branches = payload.branches;
604 clamp_list_state(&mut tab.branch_list_state, tab.branches.len());
605 tab.graph_rows = payload.graph_rows;
606 tab.commits = payload.commits;
607 clamp_list_state(&mut tab.commit_list_state, tab.commits.len());
608 tab.unstaged_changes = payload.unstaged;
609 clamp_list_state(
610 &mut tab.unstaged_list_state,
611 tab.unstaged_changes.len(),
612 );
613 tab.staged_changes = payload.staged;
614 clamp_list_state(&mut tab.staged_list_state, tab.staged_changes.len());
615 tab.stashes = payload.stashes;
616 clamp_list_state(&mut tab.stash_list_state, tab.stashes.len());
617 tab.remotes = payload.remotes;
618 tab.status_message = Some("Repository loaded".into());
619 self.screen = AppScreen::Main;
620 self.save_session();
621 }
622 Err(e) => {
623 self.tabs[tab_idx].error_message = Some(e);
624 self.tabs[tab_idx].status_message = None;
625 }
626 }
627 }
628 BackgroundResult::FetchDone(res) => {
629 self.tab_mut().is_loading = false;
630 match res {
631 Ok(()) => {
632 self.tab_mut().status_message = Some("Fetched from origin".into());
633 self.refresh();
634 }
635 Err(e) => self.tab_mut().error_message = Some(format!("fetch: {e}")),
636 }
637 }
638 BackgroundResult::CommitDiffLoaded(res) => {
639 self.tab_mut().is_loading = false;
640 match res {
641 Ok(diffs) => {
642 if diffs.is_empty() {
643 let tab = self.tab_mut();
644 tab.selected_diff = None;
645 tab.commit_diffs.clear();
646 tab.commit_diff_file_index = 0;
647 tab.status_message = Some("No changes in this commit".into());
648 } else {
649 let tab = self.tab_mut();
650 tab.commit_diffs = diffs.clone();
651 tab.commit_diff_file_index = 0;
652 tab.selected_diff = Some(diffs[0].clone());
653 tab.diff_scroll = 0;
654 if diffs.len() > 1 {
655 tab.status_message = Some(format!(
656 "Showing file 1/{} — use h/l to switch files",
657 diffs.len()
658 ));
659 }
660 }
661 }
662 Err(e) => self.tab_mut().error_message = Some(format!("commit diff: {e}")),
663 }
664 }
665 BackgroundResult::CommitFileListLoaded(res) => {
666 self.tab_mut().is_loading = false;
667 match res {
668 Ok(files) => {
669 let count = files.len();
670 let tab = self.tab_mut();
671 tab.commit_files = files;
672 tab.commit_diffs.clear();
673 tab.commit_diff_file_index = 0;
674 tab.selected_diff = None;
675 tab.diff_scroll = 0;
676
677 if count == 0 {
678 tab.status_message = Some("No changes in this commit".into());
679 } else {
680 tab.status_message = Some(format!("{count} file(s) changed"));
681 let first_path = tab.commit_files[0].display_path().to_string();
683 self.load_single_file_diff(first_path);
684 }
685 }
686 Err(e) => self.tab_mut().error_message = Some(format!("file list: {e}")),
687 }
688 }
689 BackgroundResult::SingleFileDiffLoaded(res) => {
690 self.tab_mut().is_loading = false;
691 match res {
692 Ok(diff) => {
693 let tab = self.tab_mut();
694 if tab.commit_diffs.len() <= tab.commit_diff_file_index {
696 tab.commit_diffs.push(diff.clone());
697 } else {
698 tab.commit_diffs[tab.commit_diff_file_index] = diff.clone();
699 }
700 tab.selected_diff = Some(diff);
701 tab.diff_scroll = 0;
702 if tab.commit_files.len() > 1 {
703 tab.status_message = Some(format!(
704 "File {}/{} — use h/l to switch files",
705 tab.commit_diff_file_index + 1,
706 tab.commit_files.len()
707 ));
708 }
709 }
710 Err(e) => self.tab_mut().error_message = Some(format!("file diff: {e}")),
711 }
712 }
713 BackgroundResult::StagingRefreshed(res) => {
714 self.tab_mut().is_loading = false;
715 match res {
716 Ok(payload) => self.apply_staging_payload(payload),
717 Err(e) => {
718 self.tab_mut().error_message = Some(format!("staging refresh: {e}"))
719 }
720 }
721 }
722 BackgroundResult::OperationDone {
723 ok_message,
724 err_message,
725 needs_refresh,
726 needs_staging_refresh,
727 } => {
728 self.tab_mut().is_loading = false;
729 if let Some(msg) = err_message {
730 self.tab_mut().error_message = Some(msg);
731 } else if let Some(msg) = ok_message {
732 self.tab_mut().status_message = Some(msg);
733 }
734 if needs_refresh {
735 self.refresh();
736 } else if needs_staging_refresh {
737 self.refresh_staging();
738 }
739 }
740 BackgroundResult::SearchResults(res) => match res {
741 Ok(results) => {
742 self.tab_mut().search_results = results;
743 let count = self.tab().search_results.len();
744 self.tab_mut().status_message = Some(format!("{count} result(s) found"));
745 }
746 Err(e) => {
747 self.tab_mut().error_message = Some(format!("Search failed: {e}"));
748 }
749 },
750 }
751 }
752 }
753
754 pub fn maybe_auto_refresh(&mut self) {
757 if self.tab().repo_path.is_some()
758 && !self.tab().is_loading
759 && self.last_auto_refresh.elapsed() >= std::time::Duration::from_secs(3)
760 {
761 self.last_auto_refresh = std::time::Instant::now();
762 self.refresh_staging();
763 }
764 }
765
766 pub fn refresh_staging(&mut self) {
767 let repo_path = match self.tab().repo_path.clone() {
768 Some(p) => p,
769 None => {
770 self.tab_mut().error_message = Some("No repository open".into());
771 return;
772 }
773 };
774 let tx = self.bg_tx.clone();
775 std::thread::spawn(move || {
776 let res = (|| {
777 let repo = open_repo_str(&repo_path)?;
778 let unstaged = gitkraft_core::features::diff::get_working_dir_diff(&repo)
779 .map_err(|e| e.to_string())?;
780 let staged = gitkraft_core::features::diff::get_staged_diff(&repo)
781 .map_err(|e| e.to_string())?;
782 Ok::<_, String>(StagingPayload { unstaged, staged })
783 })();
784 let _ = tx.send(BackgroundResult::StagingRefreshed(res));
785 });
786 }
787
788 fn apply_staging_payload(&mut self, payload: StagingPayload) {
789 self.tab_mut().selected_unstaged.clear();
790 self.tab_mut().selected_staged.clear();
791 let tab = self.tab_mut();
792 tab.unstaged_changes = payload.unstaged;
793 if tab.unstaged_changes.is_empty() {
794 tab.unstaged_list_state.select(None);
795 } else if tab.unstaged_list_state.selected().is_none() {
796 tab.unstaged_list_state.select(Some(0));
797 } else if let Some(i) = tab.unstaged_list_state.selected() {
798 if i >= tab.unstaged_changes.len() {
799 tab.unstaged_list_state
800 .select(Some(tab.unstaged_changes.len() - 1));
801 }
802 }
803
804 tab.staged_changes = payload.staged;
805 if tab.staged_changes.is_empty() {
806 tab.staged_list_state.select(None);
807 } else if tab.staged_list_state.selected().is_none() {
808 tab.staged_list_state.select(Some(0));
809 } else if let Some(i) = tab.staged_list_state.selected() {
810 if i >= tab.staged_changes.len() {
811 tab.staged_list_state
812 .select(Some(tab.staged_changes.len() - 1));
813 }
814 }
815 }
816
817 pub fn stage_selected(&mut self) {
820 let idx = match self.tab().unstaged_list_state.selected() {
821 Some(i) => i,
822 None => {
823 self.tab_mut().status_message = Some("No unstaged file selected".into());
824 return;
825 }
826 };
827 let file_path = self.unstaged_file_path(idx);
828 bg_op!(self, "Staging…", staging, |repo_path| {
829 let repo = open_repo_str(&repo_path)?;
830 gitkraft_core::features::staging::stage_file(&repo, &file_path)
831 .map_err(|e| format!("stage: {e}"))?;
832 Ok(format!("Staged: {file_path}"))
833 });
834 }
835
836 pub fn unstage_selected(&mut self) {
837 let idx = match self.tab().staged_list_state.selected() {
838 Some(i) => i,
839 None => {
840 self.tab_mut().status_message = Some("No staged file selected".into());
841 return;
842 }
843 };
844 let file_path = self.staged_file_path(idx);
845 bg_op!(self, "Unstaging…", staging, |repo_path| {
846 let repo = open_repo_str(&repo_path)?;
847 gitkraft_core::features::staging::unstage_file(&repo, &file_path)
848 .map_err(|e| format!("unstage: {e}"))?;
849 Ok(format!("Unstaged: {file_path}"))
850 });
851 }
852
853 pub fn stage_all(&mut self) {
854 bg_op!(self, "Staging all…", staging, |repo_path| {
855 let repo = open_repo_str(&repo_path)?;
856 gitkraft_core::features::staging::stage_all(&repo)
857 .map_err(|e| format!("stage all: {e}"))?;
858 Ok("Staged all files".into())
859 });
860 }
861
862 pub fn unstage_all(&mut self) {
863 bg_op!(self, "Unstaging all…", staging, |repo_path| {
864 let repo = open_repo_str(&repo_path)?;
865 gitkraft_core::features::staging::unstage_all(&repo)
866 .map_err(|e| format!("unstage all: {e}"))?;
867 Ok("Unstaged all files".into())
868 });
869 }
870
871 pub fn discard_selected(&mut self) {
872 let idx = match self.tab().unstaged_list_state.selected() {
873 Some(i) => i,
874 None => {
875 self.tab_mut().status_message = Some("No unstaged file selected".into());
876 return;
877 }
878 };
879 let file_path = self.unstaged_file_path(idx);
880 self.tab_mut().confirm_discard = false;
881 bg_op!(self, "Discarding…", staging, |repo_path| {
882 let repo = open_repo_str(&repo_path)?;
883 gitkraft_core::features::staging::discard_file_changes(&repo, &file_path)
884 .map_err(|e| format!("discard: {e}"))?;
885 Ok(format!("Discarded changes: {file_path}"))
886 });
887 }
888
889 pub fn stage_files(&mut self, paths: Vec<String>) {
891 let count = paths.len();
892 bg_op!(
893 self,
894 format!("Staging {count} file(s)…"),
895 staging,
896 |repo_path| {
897 let repo = open_repo_str(&repo_path)?;
898 for fp in &paths {
899 gitkraft_core::features::staging::stage_file(&repo, fp)
900 .map_err(|e| e.to_string())?;
901 }
902 Ok(format!("{count} file(s) staged"))
903 }
904 );
905 }
906
907 pub fn unstage_files(&mut self, paths: Vec<String>) {
909 let count = paths.len();
910 bg_op!(
911 self,
912 format!("Unstaging {count} file(s)…"),
913 staging,
914 |repo_path| {
915 let repo = open_repo_str(&repo_path)?;
916 for fp in &paths {
917 gitkraft_core::features::staging::unstage_file(&repo, fp)
918 .map_err(|e| e.to_string())?;
919 }
920 Ok(format!("{count} file(s) unstaged"))
921 }
922 );
923 }
924
925 pub fn discard_files(&mut self, paths: Vec<String>) {
927 let count = paths.len();
928 bg_op!(
929 self,
930 format!("Discarding {count} file(s)…"),
931 staging,
932 |repo_path| {
933 let repo = open_repo_str(&repo_path)?;
934 for fp in &paths {
935 gitkraft_core::features::staging::discard_file_changes(&repo, fp)
936 .map_err(|e| e.to_string())?;
937 }
938 Ok(format!("{count} file(s) discarded"))
939 }
940 );
941 }
942
943 pub fn create_commit(&mut self) {
946 let msg = self.input_buffer.trim().to_string();
947 if msg.is_empty() {
948 self.tab_mut().error_message = Some("Commit message cannot be empty".into());
949 return;
950 }
951 self.input_buffer.clear();
952 bg_op!(self, "Committing…", refresh, |repo_path| {
953 let repo = open_repo_str(&repo_path)?;
954 let info = gitkraft_core::features::commits::create_commit(&repo, &msg)
955 .map_err(|e| format!("commit: {e}"))?;
956 Ok(format!("Committed: {} {}", info.short_oid, info.summary))
957 });
958 }
959
960 pub fn checkout_selected_branch(&mut self) {
963 let idx = match self.tab().branch_list_state.selected() {
964 Some(i) => i,
965 None => return,
966 };
967 if idx >= self.tab().branches.len() {
968 return;
969 }
970 let name = self.tab().branches[idx].name.clone();
971 if self.tab().branches[idx].is_head {
972 self.tab_mut().status_message = Some(format!("Already on '{name}'"));
973 return;
974 }
975 bg_op!(self, "Checking out…", refresh, |repo_path| {
976 let repo = open_repo_str(&repo_path)?;
977 gitkraft_core::features::branches::checkout_branch(&repo, &name)
978 .map_err(|e| format!("checkout: {e}"))?;
979 Ok(format!("Checked out: {name}"))
980 });
981 }
982
983 pub fn create_branch(&mut self) {
984 let name = self.input_buffer.trim().to_string();
985 if name.is_empty() {
986 self.tab_mut().error_message = Some("Branch name cannot be empty".into());
987 return;
988 }
989 self.input_buffer.clear();
990 bg_op!(self, "Creating branch…", refresh, |repo_path| {
991 let repo = open_repo_str(&repo_path)?;
992 gitkraft_core::features::branches::create_branch(&repo, &name)
993 .map_err(|e| format!("create branch: {e}"))?;
994 Ok(format!("Created branch: {name}"))
995 });
996 }
997
998 pub fn delete_selected_branch(&mut self) {
999 let idx = match self.tab().branch_list_state.selected() {
1000 Some(i) => i,
1001 None => return,
1002 };
1003 if idx >= self.tab().branches.len() {
1004 return;
1005 }
1006 if self.tab().branches[idx].is_head {
1007 self.tab_mut().error_message = Some("Cannot delete the current branch".into());
1008 return;
1009 }
1010 let name = self.tab().branches[idx].name.clone();
1011 bg_op!(self, "Deleting branch…", refresh, |repo_path| {
1012 let repo = open_repo_str(&repo_path)?;
1013 gitkraft_core::features::branches::delete_branch(&repo, &name)
1014 .map_err(|e| format!("delete branch: {e}"))?;
1015 Ok(format!("Deleted branch: {name}"))
1016 });
1017 }
1018
1019 pub fn stash_save(&mut self) {
1022 let msg = if self.tab().stash_message_buffer.trim().is_empty() {
1023 None
1024 } else {
1025 Some(self.tab().stash_message_buffer.trim().to_string())
1026 };
1027 self.tab_mut().stash_message_buffer.clear();
1028 bg_op!(self, "Stashing…", refresh, |repo_path| {
1029 let mut repo = open_repo_str(&repo_path)?;
1030 let entry = gitkraft_core::features::stash::stash_save(&mut repo, msg.as_deref())
1031 .map_err(|e| format!("stash save: {e}"))?;
1032 Ok(format!("Stashed: {}", entry.message))
1033 });
1034 }
1035
1036 pub fn stash_pop_selected(&mut self) {
1037 let idx = self.tab().stash_list_state.selected().unwrap_or(0);
1038 if idx >= self.tab().stashes.len() {
1039 self.tab_mut().error_message = Some("No stash selected".into());
1040 return;
1041 }
1042 bg_op!(self, "Popping stash…", refresh, |repo_path| {
1043 let mut repo = open_repo_str(&repo_path)?;
1044 gitkraft_core::features::stash::stash_pop(&mut repo, idx)
1045 .map_err(|e| format!("stash pop: {e}"))?;
1046 Ok(format!("Stash @{{{idx}}} popped"))
1047 });
1048 }
1049
1050 pub fn stash_drop_selected(&mut self) {
1051 let idx = self.tab().stash_list_state.selected().unwrap_or(0);
1052 if idx >= self.tab().stashes.len() {
1053 self.tab_mut().error_message = Some("No stash to drop".into());
1054 return;
1055 }
1056 bg_op!(self, "Dropping stash…", refresh, |repo_path| {
1057 let mut repo = open_repo_str(&repo_path)?;
1058 gitkraft_core::features::stash::stash_drop(&mut repo, idx)
1059 .map_err(|e| format!("stash drop: {e}"))?;
1060 Ok(format!("Stash @{{{idx}}} dropped"))
1061 });
1062 }
1063
1064 pub fn load_commit_diff(&mut self) {
1068 let idx = match self.tab().commit_list_state.selected() {
1069 Some(i) => i,
1070 None => return,
1071 };
1072 if idx >= self.tab().commits.len() {
1073 return;
1074 }
1075 let oid = self.tab().commits[idx].oid.clone();
1076 self.tab_mut().selected_commit_oid = Some(oid.clone());
1077 bg_task!(
1078 self,
1079 "Loading files…",
1080 BackgroundResult::CommitFileListLoaded,
1081 |repo_path| {
1082 let repo = open_repo_str(&repo_path)?;
1083 gitkraft_core::features::diff::get_commit_file_list(&repo, &oid)
1084 .map_err(|e| e.to_string())
1085 }
1086 );
1087 }
1088
1089 pub fn load_single_file_diff(&mut self, file_path: String) {
1091 let oid = match self.tab().selected_commit_oid.clone() {
1092 Some(o) => o,
1093 None => return,
1094 };
1095 bg_task!(
1096 self,
1097 "Loading diff…",
1098 BackgroundResult::SingleFileDiffLoaded,
1099 |repo_path| {
1100 let repo = open_repo_str(&repo_path)?;
1101 gitkraft_core::features::diff::get_single_file_diff(&repo, &oid, &file_path)
1102 .map_err(|e| e.to_string())
1103 }
1104 );
1105 }
1106
1107 pub fn next_diff_file(&mut self) {
1109 if self.tab().commit_files.is_empty() {
1110 return;
1111 }
1112 let new_index = (self.tab().commit_diff_file_index + 1) % self.tab().commit_files.len();
1113 self.tab_mut().commit_diff_file_index = new_index;
1114 let file_path = self.tab().commit_files[self.tab().commit_diff_file_index]
1115 .display_path()
1116 .to_string();
1117 self.tab_mut().diff_scroll = 0;
1118 self.tab_mut().status_message = Some(format!(
1119 "File {}/{}",
1120 self.tab().commit_diff_file_index + 1,
1121 self.tab().commit_files.len()
1122 ));
1123 self.load_single_file_diff(file_path);
1124 }
1125
1126 pub fn prev_diff_file(&mut self) {
1128 if self.tab().commit_files.is_empty() {
1129 return;
1130 }
1131 let new_index = if self.tab().commit_diff_file_index == 0 {
1132 self.tab().commit_files.len() - 1
1133 } else {
1134 self.tab().commit_diff_file_index - 1
1135 };
1136 self.tab_mut().commit_diff_file_index = new_index;
1137 let file_path = self.tab().commit_files[self.tab().commit_diff_file_index]
1138 .display_path()
1139 .to_string();
1140 self.tab_mut().diff_scroll = 0;
1141 self.tab_mut().status_message = Some(format!(
1142 "File {}/{}",
1143 self.tab().commit_diff_file_index + 1,
1144 self.tab().commit_files.len()
1145 ));
1146 self.load_single_file_diff(file_path);
1147 }
1148
1149 pub fn search_commits(&mut self, query: String) {
1152 let repo_path = match self.tab().repo_path.clone() {
1153 Some(p) => p,
1154 None => return,
1155 };
1156 self.tab_mut().search_query = query.clone();
1157 if query.trim().len() < 2 {
1158 self.tab_mut().search_results.clear();
1159 return;
1160 }
1161 let tx = self.bg_tx.clone();
1162 std::thread::spawn(move || {
1163 let res = (|| {
1164 let repo = open_repo_str(&repo_path)?;
1165 gitkraft_core::features::log::search_commits(&repo, &query, 100)
1166 .map_err(|e| e.to_string())
1167 })();
1168 let _ = tx.send(BackgroundResult::SearchResults(res));
1169 });
1170 }
1171
1172 pub fn load_commit_diff_by_oid(&mut self) {
1174 let oid = match self.tab().selected_commit_oid.clone() {
1175 Some(o) => o,
1176 None => return,
1177 };
1178 bg_task!(
1179 self,
1180 "Loading files…",
1181 BackgroundResult::CommitFileListLoaded,
1182 |repo_path| {
1183 let repo = open_repo_str(&repo_path)?;
1184 gitkraft_core::features::diff::get_commit_file_list(&repo, &oid)
1185 .map_err(|e| e.to_string())
1186 }
1187 );
1188 }
1189
1190 pub fn close_repo(&mut self) {
1191 self.tabs[self.active_tab_index] = RepoTab::new();
1192 self.input_buffer.clear();
1193 self.show_theme_panel = false;
1194 self.show_options_panel = false;
1195 self.screen = AppScreen::Welcome;
1196 if let Ok(settings) = gitkraft_core::features::persistence::load_settings() {
1198 self.recent_repos = settings.recent_repos;
1199 }
1200 self.save_session();
1201 }
1202
1203 pub fn refresh_browser(&mut self) {
1205 let mut entries = Vec::new();
1206 if let Ok(read_dir) = std::fs::read_dir(&self.browser_dir) {
1207 for entry in read_dir.flatten() {
1208 let path = entry.path();
1209 if path.is_dir() {
1211 entries.push(path);
1212 }
1213 }
1214 }
1215 entries.sort_by(|a, b| {
1216 let a_name = a
1217 .file_name()
1218 .unwrap_or_default()
1219 .to_string_lossy()
1220 .to_lowercase();
1221 let b_name = b
1222 .file_name()
1223 .unwrap_or_default()
1224 .to_string_lossy()
1225 .to_lowercase();
1226 let a_dot = a_name.starts_with('.');
1228 let b_dot = b_name.starts_with('.');
1229 a_dot.cmp(&b_dot).then(a_name.cmp(&b_name))
1230 });
1231 self.browser_entries = entries;
1232 self.browser_list_state = ListState::default();
1233 if !self.browser_entries.is_empty() {
1234 self.browser_list_state.select(Some(0));
1235 }
1236 }
1237
1238 pub fn open_browser(&mut self, start: PathBuf) {
1240 self.browser_return_screen = self.screen.clone();
1241 self.browser_dir = start;
1242 self.refresh_browser();
1243 self.screen = AppScreen::DirBrowser;
1244 }
1245 pub fn open_selected_in_editor(&mut self) {
1248 if matches!(self.editor, gitkraft_core::Editor::None) {
1249 self.tab_mut().status_message =
1250 Some("No editor configured — press E to choose one".into());
1251 return;
1252 }
1253 let file_path = match self.tab().staging_focus {
1254 StagingFocus::Unstaged => self
1255 .tab()
1256 .unstaged_list_state
1257 .selected()
1258 .and_then(|idx| self.tab().unstaged_changes.get(idx))
1259 .map(|d| d.display_path().to_string()),
1260 StagingFocus::Staged => self
1261 .tab()
1262 .staged_list_state
1263 .selected()
1264 .and_then(|idx| self.tab().staged_changes.get(idx))
1265 .map(|d| d.display_path().to_string()),
1266 };
1267 if let (Some(fp), Some(repo_path)) = (file_path, self.tab().repo_path.as_ref()) {
1268 let full_path = repo_path.join(&fp);
1269 match self.editor.open_file(&full_path) {
1270 Ok(()) => {
1271 self.tab_mut().status_message =
1272 Some(format!("Opened {} in {}", fp, self.editor));
1273 }
1274 Err(e) => {
1275 self.tab_mut().error_message = Some(format!("Failed to open editor: {e}"));
1276 }
1277 }
1278 }
1279 }
1280
1281 pub fn load_staging_diff(&mut self) {
1282 match self.tab().staging_focus {
1283 StagingFocus::Unstaged => {
1284 if let Some(idx) = self.tab().unstaged_list_state.selected() {
1285 if idx < self.tab().unstaged_changes.len() {
1286 let diff = self.tab().unstaged_changes[idx].clone();
1287 let tab = self.tab_mut();
1288 tab.selected_diff = Some(diff);
1289 tab.diff_scroll = 0;
1290 }
1291 }
1292 }
1293 StagingFocus::Staged => {
1294 if let Some(idx) = self.tab().staged_list_state.selected() {
1295 if idx < self.tab().staged_changes.len() {
1296 let diff = self.tab().staged_changes[idx].clone();
1297 let tab = self.tab_mut();
1298 tab.selected_diff = Some(diff);
1299 tab.diff_scroll = 0;
1300 }
1301 }
1302 }
1303 }
1304 }
1305
1306 pub fn fetch_remote(&mut self) {
1309 let repo_path = match self.tab().repo_path.clone() {
1310 Some(p) => p,
1311 None => return,
1312 };
1313 self.tab_mut().is_loading = true;
1314 self.tab_mut().status_message = Some("Fetching…".into());
1315 let tx = self.bg_tx.clone();
1316 std::thread::spawn(move || {
1317 let res = (|| {
1318 let repo = open_repo_str(&repo_path)?;
1319 gitkraft_core::features::remotes::fetch_remote(&repo, "origin")
1320 .map_err(|e| e.to_string())
1321 })();
1322 let _ = tx.send(BackgroundResult::FetchDone(res));
1323 });
1324 }
1325
1326 pub fn pull_rebase(&mut self) {
1327 let repo_path = match self.tab().repo_path.clone() {
1328 Some(p) => p,
1329 None => return,
1330 };
1331 self.tab_mut().is_loading = true;
1332 self.tab_mut().status_message = Some("Pulling (rebase)…".into());
1333 let tx = self.bg_tx.clone();
1334 std::thread::spawn(move || {
1335 let workdir = std::path::Path::new(&repo_path);
1336 let res = gitkraft_core::features::branches::pull_rebase(workdir, "origin");
1337 let _ = tx.send(BackgroundResult::OperationDone {
1338 ok_message: res
1339 .as_ref()
1340 .ok()
1341 .map(|_| "Pulled (rebase) from origin".into()),
1342 err_message: res.err().map(|e| format!("pull: {e}")),
1343 needs_refresh: true,
1344 needs_staging_refresh: false,
1345 });
1346 });
1347 }
1348
1349 pub fn push_branch(&mut self) {
1350 let repo_path = match self.tab().repo_path.clone() {
1351 Some(p) => p,
1352 None => return,
1353 };
1354 let branch = match self
1355 .tab()
1356 .repo_info
1357 .as_ref()
1358 .and_then(|i| i.head_branch.clone())
1359 {
1360 Some(b) => b,
1361 None => {
1362 self.tab_mut().error_message = Some("No branch checked out".into());
1363 return;
1364 }
1365 };
1366 self.tab_mut().is_loading = true;
1367 self.tab_mut().status_message = Some(format!("Pushing {branch}…"));
1368 let tx = self.bg_tx.clone();
1369 std::thread::spawn(move || {
1370 let workdir = std::path::Path::new(&repo_path);
1371 let res = gitkraft_core::features::branches::push_branch(workdir, &branch, "origin");
1372 let _ = tx.send(BackgroundResult::OperationDone {
1373 ok_message: res
1374 .as_ref()
1375 .ok()
1376 .map(|_| format!("Pushed {branch} to origin")),
1377 err_message: res.err().map(|e| format!("push: {e}")),
1378 needs_refresh: true,
1379 needs_staging_refresh: false,
1380 });
1381 });
1382 }
1383
1384 pub fn force_push_branch(&mut self) {
1385 let repo_path = match self.tab().repo_path.clone() {
1386 Some(p) => p,
1387 None => return,
1388 };
1389 let branch = match self
1390 .tab()
1391 .repo_info
1392 .as_ref()
1393 .and_then(|i| i.head_branch.clone())
1394 {
1395 Some(b) => b,
1396 None => {
1397 self.tab_mut().error_message = Some("No branch checked out".into());
1398 return;
1399 }
1400 };
1401 self.tab_mut().is_loading = true;
1402 self.tab_mut().status_message = Some(format!("Force pushing {branch}…"));
1403 let tx = self.bg_tx.clone();
1404 std::thread::spawn(move || {
1405 let workdir = std::path::Path::new(&repo_path);
1406 let res =
1407 gitkraft_core::features::branches::force_push_branch(workdir, &branch, "origin");
1408 let _ = tx.send(BackgroundResult::OperationDone {
1409 ok_message: res
1410 .as_ref()
1411 .ok()
1412 .map(|_| format!("Force pushed {branch} to origin")),
1413 err_message: res.err().map(|e| format!("force push: {e}")),
1414 needs_refresh: true,
1415 needs_staging_refresh: false,
1416 });
1417 });
1418 }
1419
1420 pub fn merge_selected_branch(&mut self) {
1421 let repo_path = match self.tab().repo_path.clone() {
1422 Some(p) => p,
1423 None => return,
1424 };
1425 let branch_name = match self.tab().branch_list_state.selected() {
1426 Some(idx) => match self.tab().branches.get(idx) {
1427 Some(b) => b.name.clone(),
1428 None => return,
1429 },
1430 None => return,
1431 };
1432 self.tab_mut().is_loading = true;
1433 self.tab_mut().status_message = Some(format!("Merging {branch_name}…"));
1434 let tx = self.bg_tx.clone();
1435 std::thread::spawn(move || {
1436 let res = (|| {
1437 let repo = open_repo_str(&repo_path)?;
1438 gitkraft_core::features::branches::merge_branch(&repo, &branch_name)
1439 .map_err(|e| e.to_string())
1440 })();
1441 let _ = tx.send(BackgroundResult::OperationDone {
1442 ok_message: res.as_ref().ok().map(|_| format!("Merged {branch_name}")),
1443 err_message: res.err(),
1444 needs_refresh: true,
1445 needs_staging_refresh: false,
1446 });
1447 });
1448 }
1449
1450 pub fn rebase_onto_selected_branch(&mut self) {
1451 let repo_path = match self.tab().repo_path.clone() {
1452 Some(p) => p,
1453 None => return,
1454 };
1455 let branch_name = match self.tab().branch_list_state.selected() {
1456 Some(idx) => match self.tab().branches.get(idx) {
1457 Some(b) => b.name.clone(),
1458 None => return,
1459 },
1460 None => return,
1461 };
1462 self.tab_mut().is_loading = true;
1463 self.tab_mut().status_message = Some(format!("Rebasing onto {branch_name}…"));
1464 let tx = self.bg_tx.clone();
1465 std::thread::spawn(move || {
1466 let workdir = std::path::Path::new(&repo_path);
1467 let res = gitkraft_core::features::branches::rebase_onto(workdir, &branch_name);
1468 let _ = tx.send(BackgroundResult::OperationDone {
1469 ok_message: res
1470 .as_ref()
1471 .ok()
1472 .map(|_| format!("Rebased onto {branch_name}")),
1473 err_message: res.err().map(|e| format!("rebase: {e}")),
1474 needs_refresh: true,
1475 needs_staging_refresh: false,
1476 });
1477 });
1478 }
1479
1480 pub fn revert_selected_commit(&mut self) {
1481 let repo_path = match self.tab().repo_path.clone() {
1482 Some(p) => p,
1483 None => return,
1484 };
1485 let oid = match self.tab().commit_list_state.selected() {
1486 Some(idx) => match self.tab().commits.get(idx) {
1487 Some(c) => c.oid.clone(),
1488 None => return,
1489 },
1490 None => return,
1491 };
1492 self.tab_mut().is_loading = true;
1493 self.tab_mut().status_message = Some("Reverting commit…".into());
1494 let tx = self.bg_tx.clone();
1495 std::thread::spawn(move || {
1496 let workdir = std::path::Path::new(&repo_path);
1497 let res = gitkraft_core::features::repo::revert_commit(workdir, &oid);
1498 let _ = tx.send(BackgroundResult::OperationDone {
1499 ok_message: res.as_ref().ok().map(|_| format!("Reverted {}", &oid[..7])),
1500 err_message: res.err().map(|e| format!("revert: {e}")),
1501 needs_refresh: true,
1502 needs_staging_refresh: false,
1503 });
1504 });
1505 }
1506
1507 pub fn reset_to_selected_commit(&mut self, mode: &str) {
1508 let repo_path = match self.tab().repo_path.clone() {
1509 Some(p) => p,
1510 None => return,
1511 };
1512 let oid = match self.tab().commit_list_state.selected() {
1513 Some(idx) => match self.tab().commits.get(idx) {
1514 Some(c) => c.oid.clone(),
1515 None => return,
1516 },
1517 None => return,
1518 };
1519 let mode_owned = mode.to_string();
1520 self.tab_mut().is_loading = true;
1521 self.tab_mut().status_message = Some(format!("Resetting ({mode})…"));
1522 let tx = self.bg_tx.clone();
1523 std::thread::spawn(move || {
1524 let workdir = std::path::Path::new(&repo_path);
1525 let res = gitkraft_core::features::repo::reset_to_commit(workdir, &oid, &mode_owned);
1526 let _ = tx.send(BackgroundResult::OperationDone {
1527 ok_message: res
1528 .as_ref()
1529 .ok()
1530 .map(|_| format!("Reset ({mode_owned}) to {}", &oid[..7])),
1531 err_message: res.err().map(|e| format!("reset: {e}")),
1532 needs_refresh: true,
1533 needs_staging_refresh: false,
1534 });
1535 });
1536 }
1537
1538 fn unstaged_file_path(&self, idx: usize) -> String {
1541 if idx >= self.tab().unstaged_changes.len() {
1542 return String::new();
1543 }
1544 self.tab().unstaged_changes[idx].display_path().to_owned()
1545 }
1546
1547 fn staged_file_path(&self, idx: usize) -> String {
1548 if idx >= self.tab().staged_changes.len() {
1549 return String::new();
1550 }
1551 self.tab().staged_changes[idx].display_path().to_owned()
1552 }
1553}
1554
1555fn open_repo_str(path: &std::path::Path) -> Result<git2::Repository, String> {
1559 gitkraft_core::features::repo::open_repo(path).map_err(|e| e.to_string())
1560}
1561fn theme_name_to_index(name: &str) -> usize {
1563 gitkraft_core::theme_index_by_name(name)
1564}
1565
1566fn clamp_list_state(state: &mut ListState, len: usize) {
1568 if len == 0 {
1569 state.select(None);
1570 } else if state.selected().is_none() {
1571 state.select(Some(0));
1572 } else if let Some(i) = state.selected() {
1573 if i >= len {
1574 state.select(Some(len - 1));
1575 }
1576 }
1577}
1578
1579fn load_repo_blocking(path: &std::path::Path) -> Result<RepoPayload, String> {
1582 let mut repo = open_repo_str(path)?;
1583
1584 let info = gitkraft_core::features::repo::get_repo_info(&repo).map_err(|e| e.to_string())?;
1585 let branches =
1586 gitkraft_core::features::branches::list_branches(&repo).map_err(|e| e.to_string())?;
1587 let commits =
1588 gitkraft_core::features::commits::list_commits(&repo, 500).map_err(|e| e.to_string())?;
1589 let graph_rows = gitkraft_core::features::graph::build_graph(&commits);
1590 let unstaged =
1591 gitkraft_core::features::diff::get_working_dir_diff(&repo).map_err(|e| e.to_string())?;
1592 let staged =
1593 gitkraft_core::features::diff::get_staged_diff(&repo).map_err(|e| e.to_string())?;
1594 let remotes =
1595 gitkraft_core::features::remotes::list_remotes(&repo).map_err(|e| e.to_string())?;
1596 let stashes =
1597 gitkraft_core::features::stash::list_stashes(&mut repo).map_err(|e| e.to_string())?;
1598
1599 Ok(RepoPayload {
1600 info,
1601 branches,
1602 commits,
1603 graph_rows,
1604 unstaged,
1605 staged,
1606 stashes,
1607 remotes,
1608 })
1609}
1610
1611#[cfg(test)]
1612mod tests {
1613 use super::*;
1614
1615 #[test]
1616 fn new_app_defaults() {
1617 let app = App::new();
1618 assert!(!app.should_quit);
1619 assert_eq!(app.screen, AppScreen::Welcome);
1620 assert_eq!(app.input_mode, InputMode::Normal);
1621 assert!(app.tab().commits.is_empty());
1622 assert!(app.tab().branches.is_empty());
1623 assert!(app.tab().repo_path.is_none());
1624 assert_eq!(app.tabs.len(), 1);
1625 assert_eq!(app.active_tab_index, 0);
1626 }
1627
1628 #[test]
1629 fn cycle_theme_next_wraps() {
1630 let mut app = App::new();
1631 app.current_theme_index = 0;
1632 app.cycle_theme_next();
1633 assert_eq!(app.current_theme_index, 1);
1634 for _ in 0..26 {
1636 app.cycle_theme_next();
1637 }
1638 assert_eq!(app.current_theme_index, 0); }
1640
1641 #[test]
1642 fn cycle_theme_prev_wraps() {
1643 let mut app = App::new();
1644 app.current_theme_index = 0;
1645 app.cycle_theme_prev();
1646 assert_eq!(app.current_theme_index, 26); }
1648
1649 #[test]
1650 fn theme_returns_struct() {
1651 let mut app = App::new();
1652 app.current_theme_index = 0;
1653 let theme = app.theme();
1654 assert_eq!(
1656 format!("{:?}", theme.border_active),
1657 format!("{:?}", ratatui::style::Color::Rgb(88, 166, 255))
1658 );
1659 }
1660
1661 #[test]
1662 fn theme_name_to_index_known() {
1663 assert_eq!(theme_name_to_index("Default"), 0);
1664 assert_eq!(theme_name_to_index("Dracula"), 8);
1665 assert_eq!(theme_name_to_index("Nord"), 9);
1666 }
1667
1668 #[test]
1669 fn theme_name_to_index_unknown_returns_zero() {
1670 assert_eq!(theme_name_to_index("NonExistentTheme"), 0);
1671 assert_eq!(theme_name_to_index(""), 0);
1672 }
1673
1674 #[test]
1675 fn tab_management_new_tab() {
1676 let mut app = App::new();
1677 assert_eq!(app.tabs.len(), 1);
1678 assert_eq!(app.active_tab_index, 0);
1679
1680 app.new_tab();
1681 assert_eq!(app.tabs.len(), 2);
1682 assert_eq!(app.active_tab_index, 1);
1683
1684 app.new_tab();
1685 assert_eq!(app.tabs.len(), 3);
1686 assert_eq!(app.active_tab_index, 2);
1687 }
1688
1689 #[test]
1690 fn tab_management_close_tab() {
1691 let mut app = App::new();
1692 app.new_tab();
1693 app.new_tab();
1694 assert_eq!(app.tabs.len(), 3);
1695 assert_eq!(app.active_tab_index, 2);
1696
1697 app.close_tab();
1698 assert_eq!(app.tabs.len(), 2);
1699 assert_eq!(app.active_tab_index, 1);
1700
1701 app.close_tab();
1702 assert_eq!(app.tabs.len(), 1);
1703 assert_eq!(app.active_tab_index, 0);
1704
1705 app.close_tab();
1707 assert_eq!(app.tabs.len(), 1);
1708 assert_eq!(app.active_tab_index, 0);
1709 }
1710
1711 #[test]
1712 fn tab_management_next_prev() {
1713 let mut app = App::new();
1714 app.new_tab();
1715 app.new_tab();
1716 app.next_tab();
1719 assert_eq!(app.active_tab_index, 0); app.next_tab();
1722 assert_eq!(app.active_tab_index, 1);
1723
1724 app.prev_tab();
1725 assert_eq!(app.active_tab_index, 0);
1726
1727 app.prev_tab();
1728 assert_eq!(app.active_tab_index, 2); }
1730
1731 #[test]
1732 fn repo_tab_display_name() {
1733 let tab = RepoTab::new();
1734 assert_eq!(tab.display_name(), "New Tab");
1735
1736 let mut tab2 = RepoTab::new();
1737 tab2.repo_path = Some(PathBuf::from("/home/user/projects/my-repo"));
1738 assert_eq!(tab2.display_name(), "my-repo");
1739 }
1740
1741 #[test]
1742 fn repo_tab_search_defaults() {
1743 let tab = RepoTab::new();
1744 assert!(!tab.search_active);
1745 assert!(tab.search_query.is_empty());
1746 assert!(tab.search_results.is_empty());
1747 }
1748
1749 #[test]
1750 fn repo_tab_new_has_empty_state() {
1751 let tab = RepoTab::new();
1752 assert!(tab.repo_path.is_none());
1753 assert!(tab.commits.is_empty());
1754 assert!(tab.branches.is_empty());
1755 assert!(tab.unstaged_changes.is_empty());
1756 assert!(tab.staged_changes.is_empty());
1757 assert!(tab.stashes.is_empty());
1758 assert!(tab.remotes.is_empty());
1759 assert!(tab.commit_files.is_empty());
1760 assert!(tab.selected_commit_oid.is_none());
1761 assert!(!tab.is_loading);
1762 assert!(!tab.confirm_discard);
1763 assert_eq!(tab.diff_scroll, 0);
1764 assert_eq!(tab.commit_diff_file_index, 0);
1765 }
1766
1767 #[test]
1768 fn new_tab_switches_to_welcome() {
1769 let mut app = App::new();
1770 app.screen = AppScreen::Main;
1771 app.new_tab();
1772 assert_eq!(app.screen, AppScreen::Welcome);
1773 assert_eq!(app.active_tab_index, 1);
1774 }
1775
1776 #[test]
1777 fn close_tab_last_tab_resets() {
1778 let mut app = App::new();
1779 app.tab_mut().search_active = true;
1781 app.tab_mut().search_query = "test".into();
1782
1783 app.close_tab();
1784
1785 assert_eq!(app.tabs.len(), 1);
1787 assert!(!app.tab().search_active);
1788 assert!(app.tab().search_query.is_empty());
1789 }
1790
1791 #[test]
1792 fn close_tab_middle_adjusts_index() {
1793 let mut app = App::new();
1794 app.new_tab();
1795 app.new_tab();
1796 app.active_tab_index = 1; app.close_tab();
1800
1801 assert_eq!(app.tabs.len(), 2);
1802 assert_eq!(app.active_tab_index, 1); }
1804
1805 #[test]
1806 fn next_tab_single_tab_no_change() {
1807 let mut app = App::new();
1808 app.next_tab();
1809 assert_eq!(app.active_tab_index, 0);
1810 }
1811
1812 #[test]
1813 fn prev_tab_single_tab_no_change() {
1814 let mut app = App::new();
1815 app.prev_tab();
1816 assert_eq!(app.active_tab_index, 0);
1817 }
1818
1819 #[test]
1820 fn open_browser_sets_dir_browser_screen() {
1821 let mut app = App::new();
1822 app.screen = AppScreen::Main;
1823 app.open_browser(PathBuf::from("/tmp"));
1824 assert_eq!(app.screen, AppScreen::DirBrowser);
1825 assert_eq!(app.browser_return_screen, AppScreen::Main);
1826 }
1827
1828 #[test]
1829 fn repo_tab_selected_defaults_empty() {
1830 let tab = RepoTab::new();
1831 assert!(tab.selected_unstaged.is_empty());
1832 assert!(tab.selected_staged.is_empty());
1833 }
1834
1835 #[test]
1836 fn repo_tab_selected_toggle() {
1837 let mut tab = RepoTab::new();
1838 tab.selected_unstaged.insert(0);
1839 tab.selected_unstaged.insert(2);
1840 assert_eq!(tab.selected_unstaged.len(), 2);
1841 assert!(tab.selected_unstaged.contains(&0));
1842 tab.selected_unstaged.remove(&0);
1843 assert_eq!(tab.selected_unstaged.len(), 1);
1844 assert!(!tab.selected_unstaged.contains(&0));
1845 }
1846
1847 #[test]
1848 fn auto_refresh_field_exists() {
1849 let app = App::new();
1850 assert!(app.last_auto_refresh.elapsed() < std::time::Duration::from_secs(1));
1851 }
1852
1853 #[test]
1854 fn editor_defaults_from_settings() {
1855 let app = App::new();
1856 let _ = app.editor.display_name();
1858 }
1859
1860 #[test]
1861 fn pull_rebase_sets_loading() {
1862 let mut app = App::new();
1863 app.tabs[0].repo_path = Some(PathBuf::from("/tmp/fake-repo"));
1864 app.pull_rebase();
1865 assert!(app.tab().is_loading);
1866 assert_eq!(
1867 app.tab().status_message.as_deref(),
1868 Some("Pulling (rebase)…")
1869 );
1870 }
1871
1872 #[test]
1873 fn push_branch_requires_head_branch() {
1874 let mut app = App::new();
1875 app.tabs[0].repo_path = Some(PathBuf::from("/tmp/fake-repo"));
1876 app.push_branch();
1878 assert!(app.tab().error_message.is_some());
1879 }
1880
1881 #[test]
1882 fn force_push_requires_head_branch() {
1883 let mut app = App::new();
1884 app.tabs[0].repo_path = Some(PathBuf::from("/tmp/fake-repo"));
1885 app.force_push_branch();
1886 assert!(app.tab().error_message.is_some());
1887 }
1888
1889 #[test]
1890 fn merge_selected_branch_no_selection() {
1891 let mut app = App::new();
1892 app.tabs[0].repo_path = Some(PathBuf::from("/tmp/fake-repo"));
1893 app.merge_selected_branch();
1895 assert!(!app.tab().is_loading);
1896 }
1897
1898 #[test]
1899 fn rebase_onto_selected_no_selection() {
1900 let mut app = App::new();
1901 app.tabs[0].repo_path = Some(PathBuf::from("/tmp/fake-repo"));
1902 app.rebase_onto_selected_branch();
1903 assert!(!app.tab().is_loading);
1904 }
1905
1906 #[test]
1907 fn revert_selected_commit_no_selection() {
1908 let mut app = App::new();
1909 app.tabs[0].repo_path = Some(PathBuf::from("/tmp/fake-repo"));
1910 app.revert_selected_commit();
1911 assert!(!app.tab().is_loading);
1912 }
1913
1914 #[test]
1915 fn reset_to_selected_commit_no_selection() {
1916 let mut app = App::new();
1917 app.tabs[0].repo_path = Some(PathBuf::from("/tmp/fake-repo"));
1918 app.reset_to_selected_commit("soft");
1919 assert!(!app.tab().is_loading);
1920 }
1921
1922 #[test]
1923 fn open_repo_creates_new_tab_when_current_has_repo() {
1924 let mut app = App::new();
1925 app.tabs[0].repo_path = Some(PathBuf::from("/tmp/repo1"));
1926 app.screen = AppScreen::Main;
1927 let initial_tabs = app.tabs.len();
1929 if app.tab().repo_path.is_some() {
1930 app.new_tab();
1931 }
1932 assert_eq!(app.tabs.len(), initial_tabs + 1);
1933 }
1934}