diff --git a/CHANGELOG.md b/CHANGELOG.md index 05823362cf..74b3013728 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added +* support choosing checkout branch method when status is not empty [[@fatpandac](/~https://github.com/fatpandac)] ([#2404](/~https://github.com/extrawurst/gitui/issues/2404)) + ## [0.27.0] - 2024-01-14 **new: manage remotes** diff --git a/asyncgit/src/sync/status.rs b/asyncgit/src/sync/status.rs index 1cd5bcc847..7a22c70205 100644 --- a/asyncgit/src/sync/status.rs +++ b/asyncgit/src/sync/status.rs @@ -195,3 +195,54 @@ pub fn get_status( Ok(res) } + +/// discard all changes in the working directory +pub fn discard_status(repo_path: &RepoPath) -> Result { + let repo = repo(repo_path)?; + let statuses = repo.statuses(None)?; + + for status in statuses.iter() { + if status.status().is_wt_modified() + || status.status().is_wt_new() + { + let oid = repo.head()?.target().unwrap(); + let obj = repo.find_object(oid, None)?; + + repo.checkout_tree(&obj, None)?; + repo.reset(&obj, git2::ResetType::Hard, None)?; + } + } + + Ok(true) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sync::tests::repo_init; + use std::{fs::File, io::Write, path::Path}; + + #[test] + fn test_discard_status() { + let file_path = Path::new("foo"); + let (_td, repo) = repo_init().unwrap(); + let root = repo.path().parent().unwrap(); + let repo_path: &RepoPath = + &root.as_os_str().to_str().unwrap().into(); + + File::create(root.join(file_path)) + .unwrap() + .write_all(b"test\nfoo") + .unwrap(); + + let statuses = get_status(repo_path, StatusType::WorkingDir, None) + .unwrap(); + assert_eq!(statuses.len(), 1); + + discard_status(repo_path).unwrap(); + + let statuses = get_status(repo_path, StatusType::WorkingDir, None) + .unwrap(); + assert_eq!(statuses.len(), 0); + } +} diff --git a/src/app.rs b/src/app.rs index 45037f048f..89e87b4633 100644 --- a/src/app.rs +++ b/src/app.rs @@ -10,16 +10,16 @@ use crate::{ options::{Options, SharedOptions}, popup_stack::PopupStack, popups::{ - AppOption, BlameFilePopup, BranchListPopup, CommitPopup, - CompareCommitsPopup, ConfirmPopup, CreateBranchPopup, - CreateRemotePopup, ExternalEditorPopup, FetchPopup, - FileRevlogPopup, FuzzyFindPopup, HelpPopup, - InspectCommitPopup, LogSearchPopupPopup, MsgPopup, - OptionsPopup, PullPopup, PushPopup, PushTagsPopup, - RemoteListPopup, RenameBranchPopup, RenameRemotePopup, - ResetPopup, RevisionFilesPopup, StashMsgPopup, - SubmodulesListPopup, TagCommitPopup, TagListPopup, - UpdateRemoteUrlPopup, + AppOption, BlameFilePopup, BranchListPopup, + CheckoutOptionPopup, CommitPopup, CompareCommitsPopup, + ConfirmPopup, CreateBranchPopup, CreateRemotePopup, + ExternalEditorPopup, FetchPopup, FileRevlogPopup, + FuzzyFindPopup, HelpPopup, InspectCommitPopup, + LogSearchPopupPopup, MsgPopup, OptionsPopup, PullPopup, + PushPopup, PushTagsPopup, RemoteListPopup, RenameBranchPopup, + RenameRemotePopup, ResetPopup, RevisionFilesPopup, + StashMsgPopup, SubmodulesListPopup, TagCommitPopup, + TagListPopup, UpdateRemoteUrlPopup, }, queue::{ Action, AppTabs, InternalEvent, NeedsUpdate, Queue, @@ -98,6 +98,7 @@ pub struct App { submodule_popup: SubmodulesListPopup, tags_popup: TagListPopup, reset_popup: ResetPopup, + checkout_option_popup: CheckoutOptionPopup, cmdbar: RefCell, tab: usize, revlog: Revlog, @@ -218,6 +219,7 @@ impl App { stashing_tab: Stashing::new(&env), stashlist_tab: StashList::new(&env), files_tab: FilesTab::new(&env), + checkout_option_popup: CheckoutOptionPopup::new(&env), tab: 0, queue: env.queue, theme: env.theme, @@ -493,6 +495,7 @@ impl App { fetch_popup, tag_commit_popup, reset_popup, + checkout_option_popup, create_branch_popup, create_remote_popup, rename_remote_popup, @@ -533,6 +536,7 @@ impl App { submodule_popup, tags_popup, reset_popup, + checkout_option_popup, create_branch_popup, rename_branch_popup, revision_files_popup, @@ -905,6 +909,9 @@ impl App { InternalEvent::CommitSearch(options) => { self.revlog.search(options); } + InternalEvent::CheckoutOption(branch, is_local) => { + self.checkout_option_popup.open(branch, is_local)?; + } }; Ok(flags) diff --git a/src/popups/branchlist.rs b/src/popups/branchlist.rs index 9eb5c57e09..2bcf108417 100644 --- a/src/popups/branchlist.rs +++ b/src/popups/branchlist.rs @@ -20,8 +20,9 @@ use asyncgit::{ checkout_remote_branch, BranchDetails, LocalBranch, RemoteBranch, }, - checkout_branch, get_branches_info, BranchInfo, BranchType, - CommitId, RepoPathRef, RepoState, + checkout_branch, get_branches_info, + status::StatusType, + BranchInfo, BranchType, CommitId, RepoPathRef, RepoState, }, AsyncGitNotification, }; @@ -582,23 +583,37 @@ impl BranchListPopup { anyhow::bail!("no valid branch selected"); } - if self.local { - checkout_branch( - &self.repo.borrow(), - &self.branches[self.selection as usize].name, - )?; - self.hide(); + let status = sync::status::get_status( + &self.repo.borrow(), + StatusType::WorkingDir, + None, + ) + .unwrap(); + + let selected_branch = &self.branches[self.selection as usize]; + if status.is_empty() { + if self.local { + checkout_branch( + &self.repo.borrow(), + &selected_branch.name, + )?; + self.hide(); + } else { + checkout_remote_branch( + &self.repo.borrow(), + &selected_branch, + )?; + self.local = true; + self.update_branches()?; + } + self.queue.push(InternalEvent::Update(NeedsUpdate::ALL)); } else { - checkout_remote_branch( - &self.repo.borrow(), - &self.branches[self.selection as usize], - )?; - self.local = true; - self.update_branches()?; + self.queue.push(InternalEvent::CheckoutOption( + selected_branch.clone(), + self.local.clone(), + )); } - self.queue.push(InternalEvent::Update(NeedsUpdate::ALL)); - Ok(()) } diff --git a/src/popups/checkout_option.rs b/src/popups/checkout_option.rs new file mode 100644 index 0000000000..4e7f531872 --- /dev/null +++ b/src/popups/checkout_option.rs @@ -0,0 +1,277 @@ +use crate::components::{ + visibility_blocking, CommandBlocking, CommandInfo, Component, + DrawableComponent, EventState, +}; +use crate::queue::{InternalEvent, NeedsUpdate}; +use crate::strings::{checkout_option_to_string, CheckoutOptions}; +use crate::try_or_popup; +use crate::{ + app::Environment, + keys::{key_match, SharedKeyConfig}, + queue::Queue, + strings, + ui::{self, style::SharedTheme}, +}; +use anyhow::{Ok, Result}; +use asyncgit::sync::branch::checkout_remote_branch; +use asyncgit::sync::status::discard_status; +use asyncgit::sync::{checkout_branch, stash_save, BranchInfo}; +use asyncgit::sync::{stash_pop, RepoPath}; +use crossterm::event::Event; +use ratatui::{ + layout::{Alignment, Rect}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, + Frame, +}; + +pub struct CheckoutOptionPopup { + queue: Queue, + repo: RepoPath, + local: bool, + branch: Option, + option: CheckoutOptions, + visible: bool, + key_config: SharedKeyConfig, + theme: SharedTheme, +} + +impl CheckoutOptionPopup { + /// + pub fn new(env: &Environment) -> Self { + Self { + queue: env.queue.clone(), + repo: env.repo.borrow().clone(), + local: false, + branch: None, + option: CheckoutOptions::StashAndReapply, + visible: false, + key_config: env.key_config.clone(), + theme: env.theme.clone(), + } + } + + fn get_text(&self, _width: u16) -> Vec { + let mut txt: Vec = Vec::with_capacity(10); + + txt.push(Line::from(vec![ + Span::styled( + String::from("Switch to: "), + self.theme.text(true, false), + ), + Span::styled( + self.branch.as_ref().unwrap().name.clone(), + self.theme.commit_hash(false), + ), + ])); + + let (kind_name, kind_desc) = checkout_option_to_string(self.option); + + txt.push(Line::from(vec![ + Span::styled( + String::from("How: "), + self.theme.text(true, false), + ), + Span::styled(kind_name, self.theme.text(true, true)), + Span::styled(kind_desc, self.theme.text(true, false)), + ])); + + txt + } + + /// + pub fn open( + &mut self, + branch: BranchInfo, + is_local: bool, + ) -> Result<()> { + self.show()?; + + self.branch = Some(branch); + self.local = is_local; + + Ok(()) + } + + fn checkout(&self) -> Result<()> { + if self.local { + checkout_branch( + &self.repo, + &self.branch.as_ref().unwrap().name, + )? + } else { + checkout_remote_branch( + &self.repo, + &self.branch.as_ref().unwrap(), + )?; + } + + Ok(()) + } + + fn handle_event(&mut self) -> Result<()> { + match self.option { + CheckoutOptions::StashAndReapply => { + let stash_id = stash_save( + &self.repo, + Some("Checkout auto stash"), + true, + false, + )?; + self.checkout()?; + stash_pop(&self.repo, stash_id)?; + } + CheckoutOptions::Unchange => { + self.checkout()?; + } + CheckoutOptions::Discard => { + discard_status(&self.repo)?; + self.checkout()?; + } + } + + self.queue.push(InternalEvent::Update(NeedsUpdate::ALL)); + self.queue.push(InternalEvent::SelectBranch); + self.hide(); + + Ok(()) + } + + fn change_kind(&mut self, incr: bool) { + self.option = if incr { + match self.option { + CheckoutOptions::StashAndReapply => { + CheckoutOptions::Unchange + } + CheckoutOptions::Unchange => CheckoutOptions::Discard, + CheckoutOptions::Discard => { + CheckoutOptions::StashAndReapply + } + } + } else { + match self.option { + CheckoutOptions::StashAndReapply => { + CheckoutOptions::Discard + } + CheckoutOptions::Unchange => { + CheckoutOptions::StashAndReapply + } + CheckoutOptions::Discard => CheckoutOptions::Unchange, + } + }; + } +} + +impl DrawableComponent for CheckoutOptionPopup { + fn draw(&self, f: &mut Frame, area: Rect) -> Result<()> { + if self.is_visible() { + const SIZE: (u16, u16) = (55, 4); + let area = + ui::centered_rect_absolute(SIZE.0, SIZE.1, area); + + let width = area.width; + + f.render_widget(Clear, area); + f.render_widget( + Paragraph::new(self.get_text(width)) + .block( + Block::default() + .borders(Borders::ALL) + .title(Span::styled( + "Checkout options", + self.theme.title(true), + )) + .border_style(self.theme.block(true)), + ) + .alignment(Alignment::Left), + area, + ); + } + + Ok(()) + } +} + +impl Component for CheckoutOptionPopup { + fn commands( + &self, + out: &mut Vec, + force_all: bool, + ) -> CommandBlocking { + if self.is_visible() || force_all { + out.push( + CommandInfo::new( + strings::commands::close_popup(&self.key_config), + true, + true, + ) + .order(1), + ); + + out.push( + CommandInfo::new( + strings::commands::reset_commit(&self.key_config), + true, + true, + ) + .order(1), + ); + + out.push( + CommandInfo::new( + strings::commands::reset_type(&self.key_config), + true, + true, + ) + .order(1), + ); + } + + visibility_blocking(self) + } + + fn event( + &mut self, + event: &crossterm::event::Event, + ) -> Result { + if self.is_visible() { + if let Event::Key(key) = &event { + if key_match(key, self.key_config.keys.exit_popup) { + self.hide(); + } else if key_match( + key, + self.key_config.keys.move_down, + ) { + self.change_kind(true); + } else if key_match(key, self.key_config.keys.move_up) + { + self.change_kind(false); + } else if key_match(key, self.key_config.keys.enter) { + try_or_popup!( + self, + "checkout error:", + self.handle_event() + ); + } + } + + return Ok(EventState::Consumed); + } + + Ok(EventState::NotConsumed) + } + + fn is_visible(&self) -> bool { + self.visible + } + + fn hide(&mut self) { + self.visible = false; + } + + fn show(&mut self) -> Result<()> { + self.visible = true; + + Ok(()) + } +} diff --git a/src/popups/mod.rs b/src/popups/mod.rs index cb3ae1af74..f47f1e61ef 100644 --- a/src/popups/mod.rs +++ b/src/popups/mod.rs @@ -1,5 +1,6 @@ mod blame_file; mod branchlist; +mod checkout_option; mod commit; mod compare_commits; mod confirm; @@ -30,6 +31,7 @@ mod update_remote_url; pub use blame_file::{BlameFileOpen, BlameFilePopup}; pub use branchlist::BranchListPopup; +pub use checkout_option::CheckoutOptionPopup; pub use commit::CommitPopup; pub use compare_commits::CompareCommitsPopup; pub use confirm::ConfirmPopup; diff --git a/src/queue.rs b/src/queue.rs index 44268a851d..fcbb6ddfb4 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -8,7 +8,8 @@ use crate::{ }; use asyncgit::{ sync::{ - diff::DiffLinePosition, CommitId, LogFilterSearchOptions, + diff::DiffLinePosition, BranchInfo, CommitId, + LogFilterSearchOptions, }, PushType, }; @@ -157,6 +158,8 @@ pub enum InternalEvent { RewordCommit(CommitId), /// CommitSearch(LogFilterSearchOptions), + /// + CheckoutOption(BranchInfo, bool), } /// single threaded simple queue for components to communicate with each other diff --git a/src/strings.rs b/src/strings.rs index c4cff10f70..af26af5907 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -438,6 +438,35 @@ pub fn ellipsis_trim_start(s: &str, width: usize) -> Cow { } } +#[derive(PartialEq, Clone, Copy)] +pub enum CheckoutOptions { + StashAndReapply, + Unchange, + Discard, +} + +pub fn checkout_option_to_string( + kind: CheckoutOptions, +) -> (&'static str, &'static str) { + const CHECKOUT_OPTION_STASH_AND_REAPPLY: &str = + " 🟢 Stash and reapply changes"; + const CHECKOUT_OPTION_UNCHANGE: &str = " 🟡 Keep local changes"; + const CHECKOUT_OPTION_DISCARD: &str = + " 🔴 Discard all local changes"; + + match kind { + CheckoutOptions::StashAndReapply => { + ("Stash and reapply", CHECKOUT_OPTION_STASH_AND_REAPPLY) + } + CheckoutOptions::Unchange => { + ("Don't change", CHECKOUT_OPTION_UNCHANGE) + } + CheckoutOptions::Discard => { + ("Discard", CHECKOUT_OPTION_DISCARD) + } + } +} + pub mod commit { use crate::keys::SharedKeyConfig;