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 fn unstaged_file_path(&self, idx: usize) -> String {
1329 if idx >= self.tab().unstaged_changes.len() {
1330 return String::new();
1331 }
1332 self.tab().unstaged_changes[idx].display_path().to_owned()
1333 }
1334
1335 fn staged_file_path(&self, idx: usize) -> String {
1336 if idx >= self.tab().staged_changes.len() {
1337 return String::new();
1338 }
1339 self.tab().staged_changes[idx].display_path().to_owned()
1340 }
1341}
1342
1343fn open_repo_str(path: &std::path::Path) -> Result<git2::Repository, String> {
1347 gitkraft_core::features::repo::open_repo(path).map_err(|e| e.to_string())
1348}
1349fn theme_name_to_index(name: &str) -> usize {
1351 gitkraft_core::theme_index_by_name(name)
1352}
1353
1354fn clamp_list_state(state: &mut ListState, len: usize) {
1356 if len == 0 {
1357 state.select(None);
1358 } else if state.selected().is_none() {
1359 state.select(Some(0));
1360 } else if let Some(i) = state.selected() {
1361 if i >= len {
1362 state.select(Some(len - 1));
1363 }
1364 }
1365}
1366
1367fn load_repo_blocking(path: &std::path::Path) -> Result<RepoPayload, String> {
1370 let mut repo = open_repo_str(path)?;
1371
1372 let info = gitkraft_core::features::repo::get_repo_info(&repo).map_err(|e| e.to_string())?;
1373 let branches =
1374 gitkraft_core::features::branches::list_branches(&repo).map_err(|e| e.to_string())?;
1375 let commits =
1376 gitkraft_core::features::commits::list_commits(&repo, 500).map_err(|e| e.to_string())?;
1377 let graph_rows = gitkraft_core::features::graph::build_graph(&commits);
1378 let unstaged =
1379 gitkraft_core::features::diff::get_working_dir_diff(&repo).map_err(|e| e.to_string())?;
1380 let staged =
1381 gitkraft_core::features::diff::get_staged_diff(&repo).map_err(|e| e.to_string())?;
1382 let remotes =
1383 gitkraft_core::features::remotes::list_remotes(&repo).map_err(|e| e.to_string())?;
1384 let stashes =
1385 gitkraft_core::features::stash::list_stashes(&mut repo).map_err(|e| e.to_string())?;
1386
1387 Ok(RepoPayload {
1388 info,
1389 branches,
1390 commits,
1391 graph_rows,
1392 unstaged,
1393 staged,
1394 stashes,
1395 remotes,
1396 })
1397}
1398
1399#[cfg(test)]
1400mod tests {
1401 use super::*;
1402
1403 #[test]
1404 fn new_app_defaults() {
1405 let app = App::new();
1406 assert!(!app.should_quit);
1407 assert_eq!(app.screen, AppScreen::Welcome);
1408 assert_eq!(app.input_mode, InputMode::Normal);
1409 assert!(app.tab().commits.is_empty());
1410 assert!(app.tab().branches.is_empty());
1411 assert!(app.tab().repo_path.is_none());
1412 assert_eq!(app.tabs.len(), 1);
1413 assert_eq!(app.active_tab_index, 0);
1414 }
1415
1416 #[test]
1417 fn cycle_theme_next_wraps() {
1418 let mut app = App::new();
1419 app.current_theme_index = 0;
1420 app.cycle_theme_next();
1421 assert_eq!(app.current_theme_index, 1);
1422 for _ in 0..26 {
1424 app.cycle_theme_next();
1425 }
1426 assert_eq!(app.current_theme_index, 0); }
1428
1429 #[test]
1430 fn cycle_theme_prev_wraps() {
1431 let mut app = App::new();
1432 app.current_theme_index = 0;
1433 app.cycle_theme_prev();
1434 assert_eq!(app.current_theme_index, 26); }
1436
1437 #[test]
1438 fn theme_returns_struct() {
1439 let mut app = App::new();
1440 app.current_theme_index = 0;
1441 let theme = app.theme();
1442 assert_eq!(
1444 format!("{:?}", theme.border_active),
1445 format!("{:?}", ratatui::style::Color::Rgb(88, 166, 255))
1446 );
1447 }
1448
1449 #[test]
1450 fn theme_name_to_index_known() {
1451 assert_eq!(theme_name_to_index("Default"), 0);
1452 assert_eq!(theme_name_to_index("Dracula"), 8);
1453 assert_eq!(theme_name_to_index("Nord"), 9);
1454 }
1455
1456 #[test]
1457 fn theme_name_to_index_unknown_returns_zero() {
1458 assert_eq!(theme_name_to_index("NonExistentTheme"), 0);
1459 assert_eq!(theme_name_to_index(""), 0);
1460 }
1461
1462 #[test]
1463 fn tab_management_new_tab() {
1464 let mut app = App::new();
1465 assert_eq!(app.tabs.len(), 1);
1466 assert_eq!(app.active_tab_index, 0);
1467
1468 app.new_tab();
1469 assert_eq!(app.tabs.len(), 2);
1470 assert_eq!(app.active_tab_index, 1);
1471
1472 app.new_tab();
1473 assert_eq!(app.tabs.len(), 3);
1474 assert_eq!(app.active_tab_index, 2);
1475 }
1476
1477 #[test]
1478 fn tab_management_close_tab() {
1479 let mut app = App::new();
1480 app.new_tab();
1481 app.new_tab();
1482 assert_eq!(app.tabs.len(), 3);
1483 assert_eq!(app.active_tab_index, 2);
1484
1485 app.close_tab();
1486 assert_eq!(app.tabs.len(), 2);
1487 assert_eq!(app.active_tab_index, 1);
1488
1489 app.close_tab();
1490 assert_eq!(app.tabs.len(), 1);
1491 assert_eq!(app.active_tab_index, 0);
1492
1493 app.close_tab();
1495 assert_eq!(app.tabs.len(), 1);
1496 assert_eq!(app.active_tab_index, 0);
1497 }
1498
1499 #[test]
1500 fn tab_management_next_prev() {
1501 let mut app = App::new();
1502 app.new_tab();
1503 app.new_tab();
1504 app.next_tab();
1507 assert_eq!(app.active_tab_index, 0); app.next_tab();
1510 assert_eq!(app.active_tab_index, 1);
1511
1512 app.prev_tab();
1513 assert_eq!(app.active_tab_index, 0);
1514
1515 app.prev_tab();
1516 assert_eq!(app.active_tab_index, 2); }
1518
1519 #[test]
1520 fn repo_tab_display_name() {
1521 let tab = RepoTab::new();
1522 assert_eq!(tab.display_name(), "New Tab");
1523
1524 let mut tab2 = RepoTab::new();
1525 tab2.repo_path = Some(PathBuf::from("/home/user/projects/my-repo"));
1526 assert_eq!(tab2.display_name(), "my-repo");
1527 }
1528
1529 #[test]
1530 fn repo_tab_search_defaults() {
1531 let tab = RepoTab::new();
1532 assert!(!tab.search_active);
1533 assert!(tab.search_query.is_empty());
1534 assert!(tab.search_results.is_empty());
1535 }
1536
1537 #[test]
1538 fn repo_tab_new_has_empty_state() {
1539 let tab = RepoTab::new();
1540 assert!(tab.repo_path.is_none());
1541 assert!(tab.commits.is_empty());
1542 assert!(tab.branches.is_empty());
1543 assert!(tab.unstaged_changes.is_empty());
1544 assert!(tab.staged_changes.is_empty());
1545 assert!(tab.stashes.is_empty());
1546 assert!(tab.remotes.is_empty());
1547 assert!(tab.commit_files.is_empty());
1548 assert!(tab.selected_commit_oid.is_none());
1549 assert!(!tab.is_loading);
1550 assert!(!tab.confirm_discard);
1551 assert_eq!(tab.diff_scroll, 0);
1552 assert_eq!(tab.commit_diff_file_index, 0);
1553 }
1554
1555 #[test]
1556 fn new_tab_switches_to_welcome() {
1557 let mut app = App::new();
1558 app.screen = AppScreen::Main;
1559 app.new_tab();
1560 assert_eq!(app.screen, AppScreen::Welcome);
1561 assert_eq!(app.active_tab_index, 1);
1562 }
1563
1564 #[test]
1565 fn close_tab_last_tab_resets() {
1566 let mut app = App::new();
1567 app.tab_mut().search_active = true;
1569 app.tab_mut().search_query = "test".into();
1570
1571 app.close_tab();
1572
1573 assert_eq!(app.tabs.len(), 1);
1575 assert!(!app.tab().search_active);
1576 assert!(app.tab().search_query.is_empty());
1577 }
1578
1579 #[test]
1580 fn close_tab_middle_adjusts_index() {
1581 let mut app = App::new();
1582 app.new_tab();
1583 app.new_tab();
1584 app.active_tab_index = 1; app.close_tab();
1588
1589 assert_eq!(app.tabs.len(), 2);
1590 assert_eq!(app.active_tab_index, 1); }
1592
1593 #[test]
1594 fn next_tab_single_tab_no_change() {
1595 let mut app = App::new();
1596 app.next_tab();
1597 assert_eq!(app.active_tab_index, 0);
1598 }
1599
1600 #[test]
1601 fn prev_tab_single_tab_no_change() {
1602 let mut app = App::new();
1603 app.prev_tab();
1604 assert_eq!(app.active_tab_index, 0);
1605 }
1606
1607 #[test]
1608 fn open_browser_sets_dir_browser_screen() {
1609 let mut app = App::new();
1610 app.screen = AppScreen::Main;
1611 app.open_browser(PathBuf::from("/tmp"));
1612 assert_eq!(app.screen, AppScreen::DirBrowser);
1613 assert_eq!(app.browser_return_screen, AppScreen::Main);
1614 }
1615
1616 #[test]
1617 fn repo_tab_selected_defaults_empty() {
1618 let tab = RepoTab::new();
1619 assert!(tab.selected_unstaged.is_empty());
1620 assert!(tab.selected_staged.is_empty());
1621 }
1622
1623 #[test]
1624 fn repo_tab_selected_toggle() {
1625 let mut tab = RepoTab::new();
1626 tab.selected_unstaged.insert(0);
1627 tab.selected_unstaged.insert(2);
1628 assert_eq!(tab.selected_unstaged.len(), 2);
1629 assert!(tab.selected_unstaged.contains(&0));
1630 tab.selected_unstaged.remove(&0);
1631 assert_eq!(tab.selected_unstaged.len(), 1);
1632 assert!(!tab.selected_unstaged.contains(&0));
1633 }
1634
1635 #[test]
1636 fn auto_refresh_field_exists() {
1637 let app = App::new();
1638 assert!(app.last_auto_refresh.elapsed() < std::time::Duration::from_secs(1));
1639 }
1640
1641 #[test]
1642 fn editor_defaults_from_settings() {
1643 let app = App::new();
1644 let _ = app.editor.display_name();
1646 }
1647}