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
216impl RepoTab {
217 #[must_use]
218 pub fn new() -> Self {
219 Self {
220 repo_path: None,
221 repo_info: None,
222
223 branches: Vec::new(),
224 branch_list_state: ListState::default(),
225
226 commits: Vec::new(),
227 graph_rows: Vec::new(),
228 commit_list_state: ListState::default(),
229
230 unstaged_changes: Vec::new(),
231 staged_changes: Vec::new(),
232 unstaged_list_state: ListState::default(),
233 staged_list_state: ListState::default(),
234 staging_focus: StagingFocus::Unstaged,
235 selected_diff: None,
236 diff_scroll: 0,
237 commit_diffs: Vec::new(),
238 commit_diff_file_index: 0,
239 commit_files: Vec::new(),
240 selected_commit_oid: None,
241
242 stashes: Vec::new(),
243 stash_list_state: ListState::default(),
244 remotes: Vec::new(),
245
246 stash_message_buffer: String::new(),
247
248 search_query: String::new(),
249 search_active: false,
250 search_results: Vec::new(),
251
252 status_message: None,
253 error_message: None,
254
255 is_loading: false,
256
257 confirm_discard: false,
258 }
259 }
260
261 pub fn display_name(&self) -> String {
264 match &self.repo_path {
265 Some(p) => p
266 .file_name()
267 .map(|n| n.to_string_lossy().into_owned())
268 .unwrap_or_else(|| "New Tab".into()),
269 None => "New Tab".into(),
270 }
271 }
272}
273
274impl Default for RepoTab {
275 fn default() -> Self {
276 Self::new()
277 }
278}
279
280pub struct App {
283 pub should_quit: bool,
284 pub screen: AppScreen,
285 pub active_pane: ActivePane,
286 pub input_mode: InputMode,
287 pub input_purpose: InputPurpose,
288 pub tick_count: u64,
289
290 pub bg_rx: mpsc::Receiver<BackgroundResult>,
292 pub(crate) bg_tx: mpsc::Sender<BackgroundResult>,
294
295 pub input_buffer: String,
296
297 pub show_theme_panel: bool,
299 pub show_options_panel: bool,
301 pub current_theme_index: usize,
303 pub theme_list_state: ListState,
305
306 pub recent_repos: Vec<gitkraft_core::RepoHistoryEntry>,
308
309 pub browser_dir: PathBuf,
311 pub browser_entries: Vec<std::path::PathBuf>,
313 pub browser_list_state: ListState,
315 pub browser_return_screen: AppScreen,
317
318 pub tabs: Vec<RepoTab>,
320 pub active_tab_index: usize,
322}
323
324impl App {
325 #[must_use]
328 pub fn new() -> Self {
329 let settings = gitkraft_core::features::persistence::load_settings().unwrap_or_default();
330
331 let theme_index = theme_name_to_index(settings.theme_name.as_deref().unwrap_or(""));
332
333 let recent_repos = settings.recent_repos;
334
335 let (bg_tx, bg_rx) = mpsc::channel();
336
337 Self {
338 should_quit: false,
339 screen: AppScreen::Welcome,
340 active_pane: ActivePane::Branches,
341 input_mode: InputMode::Normal,
342 input_purpose: InputPurpose::None,
343 tick_count: 0,
344
345 bg_rx,
346 bg_tx,
347
348 input_buffer: String::new(),
349
350 show_theme_panel: false,
351 show_options_panel: false,
352 current_theme_index: theme_index,
353 theme_list_state: {
354 let mut s = ListState::default();
355 s.select(Some(theme_index));
356 s
357 },
358
359 recent_repos,
360
361 browser_dir: dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")),
362 browser_entries: Vec::new(),
363 browser_list_state: ListState::default(),
364 browser_return_screen: AppScreen::Welcome,
365
366 tabs: vec![RepoTab::new()],
367 active_tab_index: 0,
368 }
369 }
370
371 #[inline]
375 pub fn tab(&self) -> &RepoTab {
376 &self.tabs[self.active_tab_index]
377 }
378
379 #[inline]
381 pub fn tab_mut(&mut self) -> &mut RepoTab {
382 &mut self.tabs[self.active_tab_index]
383 }
384
385 pub fn new_tab(&mut self) {
389 self.tabs.push(RepoTab::new());
390 self.active_tab_index = self.tabs.len() - 1;
391 self.screen = AppScreen::Welcome;
392 if let Ok(settings) = gitkraft_core::features::persistence::load_settings() {
394 self.recent_repos = settings.recent_repos;
395 }
396 self.save_session();
397 }
398
399 pub fn close_tab(&mut self) {
401 if self.tabs.len() <= 1 {
402 self.tabs[0] = RepoTab::new();
403 self.active_tab_index = 0;
404 } else {
405 self.tabs.remove(self.active_tab_index);
406 if self.active_tab_index >= self.tabs.len() {
407 self.active_tab_index = self.tabs.len() - 1;
408 }
409 }
410 self.save_session();
411 }
412
413 pub fn next_tab(&mut self) {
415 if !self.tabs.is_empty() {
416 self.active_tab_index = (self.active_tab_index + 1) % self.tabs.len();
417 }
418 }
419
420 pub fn prev_tab(&mut self) {
422 if !self.tabs.is_empty() {
423 if self.active_tab_index == 0 {
424 self.active_tab_index = self.tabs.len() - 1;
425 } else {
426 self.active_tab_index -= 1;
427 }
428 }
429 }
430}
431
432impl Default for App {
433 fn default() -> Self {
434 Self::new()
435 }
436}
437
438impl App {
439 pub fn cycle_theme_next(&mut self) {
442 let count = 27; self.current_theme_index = (self.current_theme_index + 1) % count;
444 self.theme_list_state.select(Some(self.current_theme_index));
445 self.tab_mut().status_message = Some(format!("Theme: {}", self.current_theme_name()));
446 }
447
448 pub fn cycle_theme_prev(&mut self) {
449 let count = 27;
450 if self.current_theme_index == 0 {
451 self.current_theme_index = count - 1;
452 } else {
453 self.current_theme_index -= 1;
454 }
455 self.theme_list_state.select(Some(self.current_theme_index));
456 self.tab_mut().status_message = Some(format!("Theme: {}", self.current_theme_name()));
457 }
458
459 pub fn current_theme_name(&self) -> &'static str {
460 gitkraft_core::THEME_NAMES
461 .get(self.current_theme_index)
462 .copied()
463 .unwrap_or("Default")
464 }
465
466 pub fn theme(&self) -> crate::features::theme::palette::UiTheme {
468 crate::features::theme::palette::theme_for_index(self.current_theme_index)
469 }
470
471 pub fn save_theme(&self) {
473 let _ = gitkraft_core::features::persistence::save_theme(self.current_theme_name());
474 }
475
476 pub fn save_session(&self) {
478 let paths: Vec<std::path::PathBuf> = self
479 .tabs
480 .iter()
481 .filter_map(|t| t.repo_path.clone())
482 .collect();
483 let active = self.active_tab_index;
484 let _ = gitkraft_core::features::persistence::save_session(&paths, active);
485 }
486
487 pub fn open_repo(&mut self, path: PathBuf) {
490 self.tab_mut().error_message = None;
491 self.tab_mut().status_message = Some("Opening repository…".into());
492 self.tab_mut().is_loading = true;
493 self.tab_mut().repo_path = Some(path.clone());
494 self.screen = AppScreen::Main;
495
496 let tx = self.bg_tx.clone();
497 std::thread::spawn(move || {
498 let result = load_repo_blocking(&path);
499 let _ = tx.send(BackgroundResult::RepoLoaded { path, result });
500 });
501 self.save_session();
502 }
503
504 pub fn refresh(&mut self) {
505 self.tab_mut().error_message = None;
506 self.tab_mut().is_loading = true;
507 self.tab_mut().status_message = Some("Refreshing…".into());
508
509 let path = match self.tab().repo_path.clone() {
510 Some(p) => p,
511 None => {
512 self.tab_mut().error_message = Some("No repository open".into());
513 self.tab_mut().is_loading = false;
514 return;
515 }
516 };
517
518 let tx = self.bg_tx.clone();
519 std::thread::spawn(move || {
520 let result = load_repo_blocking(&path);
521 let _ = tx.send(BackgroundResult::RepoLoaded { path, result });
522 });
523 }
524
525 pub fn poll_background(&mut self) {
528 while let Ok(result) = self.bg_rx.try_recv() {
529 match result {
530 BackgroundResult::RepoLoaded {
531 path: loaded_path,
532 result: res,
533 } => {
534 let tab_idx = self
536 .tabs
537 .iter()
538 .position(|t| t.repo_path.as_ref() == Some(&loaded_path))
539 .unwrap_or(self.active_tab_index);
540
541 self.tabs[tab_idx].is_loading = false;
542 match res {
543 Ok(payload) => {
544 let canonical = payload.info.workdir.clone().unwrap_or_else(|| {
545 self.tabs[tab_idx].repo_path.clone().unwrap_or_default()
546 });
547 self.tabs[tab_idx].repo_path = Some(canonical.clone());
548
549 let _ = gitkraft_core::features::persistence::record_repo_opened(
551 &canonical,
552 );
553 if let Ok(settings) =
554 gitkraft_core::features::persistence::load_settings()
555 {
556 self.recent_repos = settings.recent_repos;
557 }
558
559 let tab = &mut self.tabs[tab_idx];
560 tab.repo_info = Some(payload.info);
561 tab.branches = payload.branches;
562 clamp_list_state(&mut tab.branch_list_state, tab.branches.len());
563 tab.graph_rows = payload.graph_rows;
564 tab.commits = payload.commits;
565 clamp_list_state(&mut tab.commit_list_state, tab.commits.len());
566 tab.unstaged_changes = payload.unstaged;
567 clamp_list_state(
568 &mut tab.unstaged_list_state,
569 tab.unstaged_changes.len(),
570 );
571 tab.staged_changes = payload.staged;
572 clamp_list_state(&mut tab.staged_list_state, tab.staged_changes.len());
573 tab.stashes = payload.stashes;
574 clamp_list_state(&mut tab.stash_list_state, tab.stashes.len());
575 tab.remotes = payload.remotes;
576 tab.status_message = Some("Repository loaded".into());
577 self.screen = AppScreen::Main;
578 self.save_session();
579 }
580 Err(e) => {
581 self.tabs[tab_idx].error_message = Some(e);
582 self.tabs[tab_idx].status_message = None;
583 }
584 }
585 }
586 BackgroundResult::FetchDone(res) => {
587 self.tab_mut().is_loading = false;
588 match res {
589 Ok(()) => {
590 self.tab_mut().status_message = Some("Fetched from origin".into());
591 self.refresh();
592 }
593 Err(e) => self.tab_mut().error_message = Some(format!("fetch: {e}")),
594 }
595 }
596 BackgroundResult::CommitDiffLoaded(res) => {
597 self.tab_mut().is_loading = false;
598 match res {
599 Ok(diffs) => {
600 if diffs.is_empty() {
601 let tab = self.tab_mut();
602 tab.selected_diff = None;
603 tab.commit_diffs.clear();
604 tab.commit_diff_file_index = 0;
605 tab.status_message = Some("No changes in this commit".into());
606 } else {
607 let tab = self.tab_mut();
608 tab.commit_diffs = diffs.clone();
609 tab.commit_diff_file_index = 0;
610 tab.selected_diff = Some(diffs[0].clone());
611 tab.diff_scroll = 0;
612 if diffs.len() > 1 {
613 tab.status_message = Some(format!(
614 "Showing file 1/{} — use h/l to switch files",
615 diffs.len()
616 ));
617 }
618 }
619 }
620 Err(e) => self.tab_mut().error_message = Some(format!("commit diff: {e}")),
621 }
622 }
623 BackgroundResult::CommitFileListLoaded(res) => {
624 self.tab_mut().is_loading = false;
625 match res {
626 Ok(files) => {
627 let count = files.len();
628 let tab = self.tab_mut();
629 tab.commit_files = files;
630 tab.commit_diffs.clear();
631 tab.commit_diff_file_index = 0;
632 tab.selected_diff = None;
633 tab.diff_scroll = 0;
634
635 if count == 0 {
636 tab.status_message = Some("No changes in this commit".into());
637 } else {
638 tab.status_message = Some(format!("{count} file(s) changed"));
639 let first_path = tab.commit_files[0].display_path().to_string();
641 self.load_single_file_diff(first_path);
642 }
643 }
644 Err(e) => self.tab_mut().error_message = Some(format!("file list: {e}")),
645 }
646 }
647 BackgroundResult::SingleFileDiffLoaded(res) => {
648 self.tab_mut().is_loading = false;
649 match res {
650 Ok(diff) => {
651 let tab = self.tab_mut();
652 if tab.commit_diffs.len() <= tab.commit_diff_file_index {
654 tab.commit_diffs.push(diff.clone());
655 } else {
656 tab.commit_diffs[tab.commit_diff_file_index] = diff.clone();
657 }
658 tab.selected_diff = Some(diff);
659 tab.diff_scroll = 0;
660 if tab.commit_files.len() > 1 {
661 tab.status_message = Some(format!(
662 "File {}/{} — use h/l to switch files",
663 tab.commit_diff_file_index + 1,
664 tab.commit_files.len()
665 ));
666 }
667 }
668 Err(e) => self.tab_mut().error_message = Some(format!("file diff: {e}")),
669 }
670 }
671 BackgroundResult::StagingRefreshed(res) => {
672 self.tab_mut().is_loading = false;
673 match res {
674 Ok(payload) => self.apply_staging_payload(payload),
675 Err(e) => {
676 self.tab_mut().error_message = Some(format!("staging refresh: {e}"))
677 }
678 }
679 }
680 BackgroundResult::OperationDone {
681 ok_message,
682 err_message,
683 needs_refresh,
684 needs_staging_refresh,
685 } => {
686 self.tab_mut().is_loading = false;
687 if let Some(msg) = err_message {
688 self.tab_mut().error_message = Some(msg);
689 } else if let Some(msg) = ok_message {
690 self.tab_mut().status_message = Some(msg);
691 }
692 if needs_refresh {
693 self.refresh();
694 } else if needs_staging_refresh {
695 self.refresh_staging();
696 }
697 }
698 BackgroundResult::SearchResults(res) => match res {
699 Ok(results) => {
700 self.tab_mut().search_results = results;
701 let count = self.tab().search_results.len();
702 self.tab_mut().status_message = Some(format!("{count} result(s) found"));
703 }
704 Err(e) => {
705 self.tab_mut().error_message = Some(format!("Search failed: {e}"));
706 }
707 },
708 }
709 }
710 }
711
712 pub fn refresh_staging(&mut self) {
714 let repo_path = match self.tab().repo_path.clone() {
715 Some(p) => p,
716 None => {
717 self.tab_mut().error_message = Some("No repository open".into());
718 return;
719 }
720 };
721 let tx = self.bg_tx.clone();
722 std::thread::spawn(move || {
723 let res = (|| {
724 let repo = open_repo_str(&repo_path)?;
725 let unstaged = gitkraft_core::features::diff::get_working_dir_diff(&repo)
726 .map_err(|e| e.to_string())?;
727 let staged = gitkraft_core::features::diff::get_staged_diff(&repo)
728 .map_err(|e| e.to_string())?;
729 Ok::<_, String>(StagingPayload { unstaged, staged })
730 })();
731 let _ = tx.send(BackgroundResult::StagingRefreshed(res));
732 });
733 }
734
735 fn apply_staging_payload(&mut self, payload: StagingPayload) {
736 let tab = self.tab_mut();
737 tab.unstaged_changes = payload.unstaged;
738 if tab.unstaged_changes.is_empty() {
739 tab.unstaged_list_state.select(None);
740 } else if tab.unstaged_list_state.selected().is_none() {
741 tab.unstaged_list_state.select(Some(0));
742 } else if let Some(i) = tab.unstaged_list_state.selected() {
743 if i >= tab.unstaged_changes.len() {
744 tab.unstaged_list_state
745 .select(Some(tab.unstaged_changes.len() - 1));
746 }
747 }
748
749 tab.staged_changes = payload.staged;
750 if tab.staged_changes.is_empty() {
751 tab.staged_list_state.select(None);
752 } else if tab.staged_list_state.selected().is_none() {
753 tab.staged_list_state.select(Some(0));
754 } else if let Some(i) = tab.staged_list_state.selected() {
755 if i >= tab.staged_changes.len() {
756 tab.staged_list_state
757 .select(Some(tab.staged_changes.len() - 1));
758 }
759 }
760 }
761
762 pub fn stage_selected(&mut self) {
765 let idx = match self.tab().unstaged_list_state.selected() {
766 Some(i) => i,
767 None => {
768 self.tab_mut().status_message = Some("No unstaged file selected".into());
769 return;
770 }
771 };
772 let file_path = self.unstaged_file_path(idx);
773 bg_op!(self, "Staging…", staging, |repo_path| {
774 let repo = open_repo_str(&repo_path)?;
775 gitkraft_core::features::staging::stage_file(&repo, &file_path)
776 .map_err(|e| format!("stage: {e}"))?;
777 Ok(format!("Staged: {file_path}"))
778 });
779 }
780
781 pub fn unstage_selected(&mut self) {
782 let idx = match self.tab().staged_list_state.selected() {
783 Some(i) => i,
784 None => {
785 self.tab_mut().status_message = Some("No staged file selected".into());
786 return;
787 }
788 };
789 let file_path = self.staged_file_path(idx);
790 bg_op!(self, "Unstaging…", staging, |repo_path| {
791 let repo = open_repo_str(&repo_path)?;
792 gitkraft_core::features::staging::unstage_file(&repo, &file_path)
793 .map_err(|e| format!("unstage: {e}"))?;
794 Ok(format!("Unstaged: {file_path}"))
795 });
796 }
797
798 pub fn stage_all(&mut self) {
799 bg_op!(self, "Staging all…", staging, |repo_path| {
800 let repo = open_repo_str(&repo_path)?;
801 gitkraft_core::features::staging::stage_all(&repo)
802 .map_err(|e| format!("stage all: {e}"))?;
803 Ok("Staged all files".into())
804 });
805 }
806
807 pub fn unstage_all(&mut self) {
808 bg_op!(self, "Unstaging all…", staging, |repo_path| {
809 let repo = open_repo_str(&repo_path)?;
810 gitkraft_core::features::staging::unstage_all(&repo)
811 .map_err(|e| format!("unstage all: {e}"))?;
812 Ok("Unstaged all files".into())
813 });
814 }
815
816 pub fn discard_selected(&mut self) {
817 let idx = match self.tab().unstaged_list_state.selected() {
818 Some(i) => i,
819 None => {
820 self.tab_mut().status_message = Some("No unstaged file selected".into());
821 return;
822 }
823 };
824 let file_path = self.unstaged_file_path(idx);
825 self.tab_mut().confirm_discard = false;
826 bg_op!(self, "Discarding…", staging, |repo_path| {
827 let repo = open_repo_str(&repo_path)?;
828 gitkraft_core::features::staging::discard_file_changes(&repo, &file_path)
829 .map_err(|e| format!("discard: {e}"))?;
830 Ok(format!("Discarded changes: {file_path}"))
831 });
832 }
833
834 pub fn create_commit(&mut self) {
837 let msg = self.input_buffer.trim().to_string();
838 if msg.is_empty() {
839 self.tab_mut().error_message = Some("Commit message cannot be empty".into());
840 return;
841 }
842 self.input_buffer.clear();
843 bg_op!(self, "Committing…", refresh, |repo_path| {
844 let repo = open_repo_str(&repo_path)?;
845 let info = gitkraft_core::features::commits::create_commit(&repo, &msg)
846 .map_err(|e| format!("commit: {e}"))?;
847 Ok(format!("Committed: {} {}", info.short_oid, info.summary))
848 });
849 }
850
851 pub fn checkout_selected_branch(&mut self) {
854 let idx = match self.tab().branch_list_state.selected() {
855 Some(i) => i,
856 None => return,
857 };
858 if idx >= self.tab().branches.len() {
859 return;
860 }
861 let name = self.tab().branches[idx].name.clone();
862 if self.tab().branches[idx].is_head {
863 self.tab_mut().status_message = Some(format!("Already on '{name}'"));
864 return;
865 }
866 bg_op!(self, "Checking out…", refresh, |repo_path| {
867 let repo = open_repo_str(&repo_path)?;
868 gitkraft_core::features::branches::checkout_branch(&repo, &name)
869 .map_err(|e| format!("checkout: {e}"))?;
870 Ok(format!("Checked out: {name}"))
871 });
872 }
873
874 pub fn create_branch(&mut self) {
875 let name = self.input_buffer.trim().to_string();
876 if name.is_empty() {
877 self.tab_mut().error_message = Some("Branch name cannot be empty".into());
878 return;
879 }
880 self.input_buffer.clear();
881 bg_op!(self, "Creating branch…", refresh, |repo_path| {
882 let repo = open_repo_str(&repo_path)?;
883 gitkraft_core::features::branches::create_branch(&repo, &name)
884 .map_err(|e| format!("create branch: {e}"))?;
885 Ok(format!("Created branch: {name}"))
886 });
887 }
888
889 pub fn delete_selected_branch(&mut self) {
890 let idx = match self.tab().branch_list_state.selected() {
891 Some(i) => i,
892 None => return,
893 };
894 if idx >= self.tab().branches.len() {
895 return;
896 }
897 if self.tab().branches[idx].is_head {
898 self.tab_mut().error_message = Some("Cannot delete the current branch".into());
899 return;
900 }
901 let name = self.tab().branches[idx].name.clone();
902 bg_op!(self, "Deleting branch…", refresh, |repo_path| {
903 let repo = open_repo_str(&repo_path)?;
904 gitkraft_core::features::branches::delete_branch(&repo, &name)
905 .map_err(|e| format!("delete branch: {e}"))?;
906 Ok(format!("Deleted branch: {name}"))
907 });
908 }
909
910 pub fn stash_save(&mut self) {
913 let msg = if self.tab().stash_message_buffer.trim().is_empty() {
914 None
915 } else {
916 Some(self.tab().stash_message_buffer.trim().to_string())
917 };
918 self.tab_mut().stash_message_buffer.clear();
919 bg_op!(self, "Stashing…", refresh, |repo_path| {
920 let mut repo = open_repo_str(&repo_path)?;
921 let entry = gitkraft_core::features::stash::stash_save(&mut repo, msg.as_deref())
922 .map_err(|e| format!("stash save: {e}"))?;
923 Ok(format!("Stashed: {}", entry.message))
924 });
925 }
926
927 pub fn stash_pop_selected(&mut self) {
928 let idx = self.tab().stash_list_state.selected().unwrap_or(0);
929 if idx >= self.tab().stashes.len() {
930 self.tab_mut().error_message = Some("No stash selected".into());
931 return;
932 }
933 bg_op!(self, "Popping stash…", refresh, |repo_path| {
934 let mut repo = open_repo_str(&repo_path)?;
935 gitkraft_core::features::stash::stash_pop(&mut repo, idx)
936 .map_err(|e| format!("stash pop: {e}"))?;
937 Ok(format!("Stash @{{{idx}}} popped"))
938 });
939 }
940
941 pub fn stash_drop_selected(&mut self) {
942 let idx = self.tab().stash_list_state.selected().unwrap_or(0);
943 if idx >= self.tab().stashes.len() {
944 self.tab_mut().error_message = Some("No stash to drop".into());
945 return;
946 }
947 bg_op!(self, "Dropping stash…", refresh, |repo_path| {
948 let mut repo = open_repo_str(&repo_path)?;
949 gitkraft_core::features::stash::stash_drop(&mut repo, idx)
950 .map_err(|e| format!("stash drop: {e}"))?;
951 Ok(format!("Stash @{{{idx}}} dropped"))
952 });
953 }
954
955 pub fn load_commit_diff(&mut self) {
959 let idx = match self.tab().commit_list_state.selected() {
960 Some(i) => i,
961 None => return,
962 };
963 if idx >= self.tab().commits.len() {
964 return;
965 }
966 let oid = self.tab().commits[idx].oid.clone();
967 self.tab_mut().selected_commit_oid = Some(oid.clone());
968 bg_task!(
969 self,
970 "Loading files…",
971 BackgroundResult::CommitFileListLoaded,
972 |repo_path| {
973 let repo = open_repo_str(&repo_path)?;
974 gitkraft_core::features::diff::get_commit_file_list(&repo, &oid)
975 .map_err(|e| e.to_string())
976 }
977 );
978 }
979
980 pub fn load_single_file_diff(&mut self, file_path: String) {
982 let oid = match self.tab().selected_commit_oid.clone() {
983 Some(o) => o,
984 None => return,
985 };
986 bg_task!(
987 self,
988 "Loading diff…",
989 BackgroundResult::SingleFileDiffLoaded,
990 |repo_path| {
991 let repo = open_repo_str(&repo_path)?;
992 gitkraft_core::features::diff::get_single_file_diff(&repo, &oid, &file_path)
993 .map_err(|e| e.to_string())
994 }
995 );
996 }
997
998 pub fn next_diff_file(&mut self) {
1000 if self.tab().commit_files.is_empty() {
1001 return;
1002 }
1003 let new_index = (self.tab().commit_diff_file_index + 1) % self.tab().commit_files.len();
1004 self.tab_mut().commit_diff_file_index = new_index;
1005 let file_path = self.tab().commit_files[self.tab().commit_diff_file_index]
1006 .display_path()
1007 .to_string();
1008 self.tab_mut().diff_scroll = 0;
1009 self.tab_mut().status_message = Some(format!(
1010 "File {}/{}",
1011 self.tab().commit_diff_file_index + 1,
1012 self.tab().commit_files.len()
1013 ));
1014 self.load_single_file_diff(file_path);
1015 }
1016
1017 pub fn prev_diff_file(&mut self) {
1019 if self.tab().commit_files.is_empty() {
1020 return;
1021 }
1022 let new_index = if self.tab().commit_diff_file_index == 0 {
1023 self.tab().commit_files.len() - 1
1024 } else {
1025 self.tab().commit_diff_file_index - 1
1026 };
1027 self.tab_mut().commit_diff_file_index = new_index;
1028 let file_path = self.tab().commit_files[self.tab().commit_diff_file_index]
1029 .display_path()
1030 .to_string();
1031 self.tab_mut().diff_scroll = 0;
1032 self.tab_mut().status_message = Some(format!(
1033 "File {}/{}",
1034 self.tab().commit_diff_file_index + 1,
1035 self.tab().commit_files.len()
1036 ));
1037 self.load_single_file_diff(file_path);
1038 }
1039
1040 pub fn search_commits(&mut self, query: String) {
1043 let repo_path = match self.tab().repo_path.clone() {
1044 Some(p) => p,
1045 None => return,
1046 };
1047 self.tab_mut().search_query = query.clone();
1048 if query.trim().len() < 2 {
1049 self.tab_mut().search_results.clear();
1050 return;
1051 }
1052 let tx = self.bg_tx.clone();
1053 std::thread::spawn(move || {
1054 let res = (|| {
1055 let repo = open_repo_str(&repo_path)?;
1056 gitkraft_core::features::log::search_commits(&repo, &query, 100)
1057 .map_err(|e| e.to_string())
1058 })();
1059 let _ = tx.send(BackgroundResult::SearchResults(res));
1060 });
1061 }
1062
1063 pub fn load_commit_diff_by_oid(&mut self) {
1065 let oid = match self.tab().selected_commit_oid.clone() {
1066 Some(o) => o,
1067 None => return,
1068 };
1069 bg_task!(
1070 self,
1071 "Loading files…",
1072 BackgroundResult::CommitFileListLoaded,
1073 |repo_path| {
1074 let repo = open_repo_str(&repo_path)?;
1075 gitkraft_core::features::diff::get_commit_file_list(&repo, &oid)
1076 .map_err(|e| e.to_string())
1077 }
1078 );
1079 }
1080
1081 pub fn close_repo(&mut self) {
1082 self.tabs[self.active_tab_index] = RepoTab::new();
1083 self.input_buffer.clear();
1084 self.show_theme_panel = false;
1085 self.show_options_panel = false;
1086 self.screen = AppScreen::Welcome;
1087 if let Ok(settings) = gitkraft_core::features::persistence::load_settings() {
1089 self.recent_repos = settings.recent_repos;
1090 }
1091 self.save_session();
1092 }
1093
1094 pub fn refresh_browser(&mut self) {
1096 let mut entries = Vec::new();
1097 if let Ok(read_dir) = std::fs::read_dir(&self.browser_dir) {
1098 for entry in read_dir.flatten() {
1099 let path = entry.path();
1100 if path.is_dir() {
1102 entries.push(path);
1103 }
1104 }
1105 }
1106 entries.sort_by(|a, b| {
1107 let a_name = a
1108 .file_name()
1109 .unwrap_or_default()
1110 .to_string_lossy()
1111 .to_lowercase();
1112 let b_name = b
1113 .file_name()
1114 .unwrap_or_default()
1115 .to_string_lossy()
1116 .to_lowercase();
1117 let a_dot = a_name.starts_with('.');
1119 let b_dot = b_name.starts_with('.');
1120 a_dot.cmp(&b_dot).then(a_name.cmp(&b_name))
1121 });
1122 self.browser_entries = entries;
1123 self.browser_list_state = ListState::default();
1124 if !self.browser_entries.is_empty() {
1125 self.browser_list_state.select(Some(0));
1126 }
1127 }
1128
1129 pub fn open_browser(&mut self, start: PathBuf) {
1131 self.browser_return_screen = self.screen.clone();
1132 self.browser_dir = start;
1133 self.refresh_browser();
1134 self.screen = AppScreen::DirBrowser;
1135 }
1136 pub fn load_staging_diff(&mut self) {
1138 match self.tab().staging_focus {
1139 StagingFocus::Unstaged => {
1140 if let Some(idx) = self.tab().unstaged_list_state.selected() {
1141 if idx < self.tab().unstaged_changes.len() {
1142 let diff = self.tab().unstaged_changes[idx].clone();
1143 let tab = self.tab_mut();
1144 tab.selected_diff = Some(diff);
1145 tab.diff_scroll = 0;
1146 }
1147 }
1148 }
1149 StagingFocus::Staged => {
1150 if let Some(idx) = self.tab().staged_list_state.selected() {
1151 if idx < self.tab().staged_changes.len() {
1152 let diff = self.tab().staged_changes[idx].clone();
1153 let tab = self.tab_mut();
1154 tab.selected_diff = Some(diff);
1155 tab.diff_scroll = 0;
1156 }
1157 }
1158 }
1159 }
1160 }
1161
1162 pub fn fetch_remote(&mut self) {
1165 let repo_path = match self.tab().repo_path.clone() {
1166 Some(p) => p,
1167 None => return,
1168 };
1169 self.tab_mut().is_loading = true;
1170 self.tab_mut().status_message = Some("Fetching…".into());
1171 let tx = self.bg_tx.clone();
1172 std::thread::spawn(move || {
1173 let res = (|| {
1174 let repo = open_repo_str(&repo_path)?;
1175 gitkraft_core::features::remotes::fetch_remote(&repo, "origin")
1176 .map_err(|e| e.to_string())
1177 })();
1178 let _ = tx.send(BackgroundResult::FetchDone(res));
1179 });
1180 }
1181
1182 fn unstaged_file_path(&self, idx: usize) -> String {
1185 if idx >= self.tab().unstaged_changes.len() {
1186 return String::new();
1187 }
1188 self.tab().unstaged_changes[idx].display_path().to_owned()
1189 }
1190
1191 fn staged_file_path(&self, idx: usize) -> String {
1192 if idx >= self.tab().staged_changes.len() {
1193 return String::new();
1194 }
1195 self.tab().staged_changes[idx].display_path().to_owned()
1196 }
1197}
1198
1199fn open_repo_str(path: &std::path::Path) -> Result<git2::Repository, String> {
1203 gitkraft_core::features::repo::open_repo(path).map_err(|e| e.to_string())
1204}
1205fn theme_name_to_index(name: &str) -> usize {
1207 gitkraft_core::theme_index_by_name(name)
1208}
1209
1210fn clamp_list_state(state: &mut ListState, len: usize) {
1212 if len == 0 {
1213 state.select(None);
1214 } else if state.selected().is_none() {
1215 state.select(Some(0));
1216 } else if let Some(i) = state.selected() {
1217 if i >= len {
1218 state.select(Some(len - 1));
1219 }
1220 }
1221}
1222
1223fn load_repo_blocking(path: &std::path::Path) -> Result<RepoPayload, String> {
1226 let mut repo = open_repo_str(path)?;
1227
1228 let info = gitkraft_core::features::repo::get_repo_info(&repo).map_err(|e| e.to_string())?;
1229 let branches =
1230 gitkraft_core::features::branches::list_branches(&repo).map_err(|e| e.to_string())?;
1231 let commits =
1232 gitkraft_core::features::commits::list_commits(&repo, 500).map_err(|e| e.to_string())?;
1233 let graph_rows = gitkraft_core::features::graph::build_graph(&commits);
1234 let unstaged =
1235 gitkraft_core::features::diff::get_working_dir_diff(&repo).map_err(|e| e.to_string())?;
1236 let staged =
1237 gitkraft_core::features::diff::get_staged_diff(&repo).map_err(|e| e.to_string())?;
1238 let remotes =
1239 gitkraft_core::features::remotes::list_remotes(&repo).map_err(|e| e.to_string())?;
1240 let stashes =
1241 gitkraft_core::features::stash::list_stashes(&mut repo).map_err(|e| e.to_string())?;
1242
1243 Ok(RepoPayload {
1244 info,
1245 branches,
1246 commits,
1247 graph_rows,
1248 unstaged,
1249 staged,
1250 stashes,
1251 remotes,
1252 })
1253}
1254
1255#[cfg(test)]
1256mod tests {
1257 use super::*;
1258
1259 #[test]
1260 fn new_app_defaults() {
1261 let app = App::new();
1262 assert!(!app.should_quit);
1263 assert_eq!(app.screen, AppScreen::Welcome);
1264 assert_eq!(app.input_mode, InputMode::Normal);
1265 assert!(app.tab().commits.is_empty());
1266 assert!(app.tab().branches.is_empty());
1267 assert!(app.tab().repo_path.is_none());
1268 assert_eq!(app.tabs.len(), 1);
1269 assert_eq!(app.active_tab_index, 0);
1270 }
1271
1272 #[test]
1273 fn cycle_theme_next_wraps() {
1274 let mut app = App::new();
1275 app.current_theme_index = 0;
1276 app.cycle_theme_next();
1277 assert_eq!(app.current_theme_index, 1);
1278 for _ in 0..26 {
1280 app.cycle_theme_next();
1281 }
1282 assert_eq!(app.current_theme_index, 0); }
1284
1285 #[test]
1286 fn cycle_theme_prev_wraps() {
1287 let mut app = App::new();
1288 app.current_theme_index = 0;
1289 app.cycle_theme_prev();
1290 assert_eq!(app.current_theme_index, 26); }
1292
1293 #[test]
1294 fn theme_returns_struct() {
1295 let mut app = App::new();
1296 app.current_theme_index = 0;
1297 let theme = app.theme();
1298 assert_eq!(
1300 format!("{:?}", theme.border_active),
1301 format!("{:?}", ratatui::style::Color::Rgb(88, 166, 255))
1302 );
1303 }
1304
1305 #[test]
1306 fn theme_name_to_index_known() {
1307 assert_eq!(theme_name_to_index("Default"), 0);
1308 assert_eq!(theme_name_to_index("Dracula"), 8);
1309 assert_eq!(theme_name_to_index("Nord"), 9);
1310 }
1311
1312 #[test]
1313 fn theme_name_to_index_unknown_returns_zero() {
1314 assert_eq!(theme_name_to_index("NonExistentTheme"), 0);
1315 assert_eq!(theme_name_to_index(""), 0);
1316 }
1317
1318 #[test]
1319 fn tab_management_new_tab() {
1320 let mut app = App::new();
1321 assert_eq!(app.tabs.len(), 1);
1322 assert_eq!(app.active_tab_index, 0);
1323
1324 app.new_tab();
1325 assert_eq!(app.tabs.len(), 2);
1326 assert_eq!(app.active_tab_index, 1);
1327
1328 app.new_tab();
1329 assert_eq!(app.tabs.len(), 3);
1330 assert_eq!(app.active_tab_index, 2);
1331 }
1332
1333 #[test]
1334 fn tab_management_close_tab() {
1335 let mut app = App::new();
1336 app.new_tab();
1337 app.new_tab();
1338 assert_eq!(app.tabs.len(), 3);
1339 assert_eq!(app.active_tab_index, 2);
1340
1341 app.close_tab();
1342 assert_eq!(app.tabs.len(), 2);
1343 assert_eq!(app.active_tab_index, 1);
1344
1345 app.close_tab();
1346 assert_eq!(app.tabs.len(), 1);
1347 assert_eq!(app.active_tab_index, 0);
1348
1349 app.close_tab();
1351 assert_eq!(app.tabs.len(), 1);
1352 assert_eq!(app.active_tab_index, 0);
1353 }
1354
1355 #[test]
1356 fn tab_management_next_prev() {
1357 let mut app = App::new();
1358 app.new_tab();
1359 app.new_tab();
1360 app.next_tab();
1363 assert_eq!(app.active_tab_index, 0); app.next_tab();
1366 assert_eq!(app.active_tab_index, 1);
1367
1368 app.prev_tab();
1369 assert_eq!(app.active_tab_index, 0);
1370
1371 app.prev_tab();
1372 assert_eq!(app.active_tab_index, 2); }
1374
1375 #[test]
1376 fn repo_tab_display_name() {
1377 let tab = RepoTab::new();
1378 assert_eq!(tab.display_name(), "New Tab");
1379
1380 let mut tab2 = RepoTab::new();
1381 tab2.repo_path = Some(PathBuf::from("/home/user/projects/my-repo"));
1382 assert_eq!(tab2.display_name(), "my-repo");
1383 }
1384
1385 #[test]
1386 fn repo_tab_search_defaults() {
1387 let tab = RepoTab::new();
1388 assert!(!tab.search_active);
1389 assert!(tab.search_query.is_empty());
1390 assert!(tab.search_results.is_empty());
1391 }
1392
1393 #[test]
1394 fn repo_tab_new_has_empty_state() {
1395 let tab = RepoTab::new();
1396 assert!(tab.repo_path.is_none());
1397 assert!(tab.commits.is_empty());
1398 assert!(tab.branches.is_empty());
1399 assert!(tab.unstaged_changes.is_empty());
1400 assert!(tab.staged_changes.is_empty());
1401 assert!(tab.stashes.is_empty());
1402 assert!(tab.remotes.is_empty());
1403 assert!(tab.commit_files.is_empty());
1404 assert!(tab.selected_commit_oid.is_none());
1405 assert!(!tab.is_loading);
1406 assert!(!tab.confirm_discard);
1407 assert_eq!(tab.diff_scroll, 0);
1408 assert_eq!(tab.commit_diff_file_index, 0);
1409 }
1410
1411 #[test]
1412 fn new_tab_switches_to_welcome() {
1413 let mut app = App::new();
1414 app.screen = AppScreen::Main;
1415 app.new_tab();
1416 assert_eq!(app.screen, AppScreen::Welcome);
1417 assert_eq!(app.active_tab_index, 1);
1418 }
1419
1420 #[test]
1421 fn close_tab_last_tab_resets() {
1422 let mut app = App::new();
1423 app.tab_mut().search_active = true;
1425 app.tab_mut().search_query = "test".into();
1426
1427 app.close_tab();
1428
1429 assert_eq!(app.tabs.len(), 1);
1431 assert!(!app.tab().search_active);
1432 assert!(app.tab().search_query.is_empty());
1433 }
1434
1435 #[test]
1436 fn close_tab_middle_adjusts_index() {
1437 let mut app = App::new();
1438 app.new_tab();
1439 app.new_tab();
1440 app.active_tab_index = 1; app.close_tab();
1444
1445 assert_eq!(app.tabs.len(), 2);
1446 assert_eq!(app.active_tab_index, 1); }
1448
1449 #[test]
1450 fn next_tab_single_tab_no_change() {
1451 let mut app = App::new();
1452 app.next_tab();
1453 assert_eq!(app.active_tab_index, 0);
1454 }
1455
1456 #[test]
1457 fn prev_tab_single_tab_no_change() {
1458 let mut app = App::new();
1459 app.prev_tab();
1460 assert_eq!(app.active_tab_index, 0);
1461 }
1462
1463 #[test]
1464 fn open_browser_sets_dir_browser_screen() {
1465 let mut app = App::new();
1466 app.screen = AppScreen::Main;
1467 app.open_browser(PathBuf::from("/tmp"));
1468 assert_eq!(app.screen, AppScreen::DirBrowser);
1469 assert_eq!(app.browser_return_screen, AppScreen::Main);
1470 }
1471}