diff --git a/CHANGELOG.md b/CHANGELOG.md index 442adfcd43..8b80b5d2e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixes * respect env vars like `GIT_CONFIG_GLOBAL` ([#2298](https://github.com/extrawurst/gitui/issues/2298)) +### Added +* add popups for viewing, adding, updating and removing remotes [[@robin-thoene](https://github.com/robin-thoene)] ([#2172](https://github.com/extrawurst/gitui/issues/2172)) + ## [0.26.3] - 2024-06-02 ### Breaking Changes diff --git a/Cargo.toml b/Cargo.toml index ee6f068259..7063d95ddb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ log = "0.4" notify = "6.1" notify-debouncer-mini = "0.4" once_cell = "1" -# pin until upgrading this does not introduce a duplicte dependency +# pin until upgrading this does not introduce a duplicate dependency parking_lot_core = "=0.9.9" ratatui = { version = "0.28", default-features = false, features = [ 'crossterm', diff --git a/asyncgit/src/sync/mod.rs b/asyncgit/src/sync/mod.rs index f95e8b0537..7dd7f722a0 100644 --- a/asyncgit/src/sync/mod.rs +++ b/asyncgit/src/sync/mod.rs @@ -79,9 +79,10 @@ pub use merge::{ }; pub use rebase::rebase_branch; pub use remotes::{ - get_default_remote, get_default_remote_for_fetch, - get_default_remote_for_push, get_remotes, push::AsyncProgress, - tags::PushTagsProgress, + add_remote, delete_remote, get_default_remote, + get_default_remote_for_fetch, get_default_remote_for_push, + get_remote_url, get_remotes, push::AsyncProgress, rename_remote, + tags::PushTagsProgress, update_remote_url, validate_remote_name, }; pub(crate) use repository::repo; pub use repository::{RepoPath, RepoPathRef}; diff --git a/asyncgit/src/sync/remotes/mod.rs b/asyncgit/src/sync/remotes/mod.rs index a97ac1ad9f..749940a192 100644 --- a/asyncgit/src/sync/remotes/mod.rs +++ b/asyncgit/src/sync/remotes/mod.rs @@ -13,7 +13,9 @@ use crate::{ ProgressPercent, }; use crossbeam_channel::Sender; -use git2::{BranchType, FetchOptions, ProxyOptions, Repository}; +use git2::{ + BranchType, FetchOptions, ProxyOptions, Remote, Repository, +}; use scopetime::scope_time; use utils::bytes2string; @@ -32,6 +34,54 @@ pub fn proxy_auto<'a>() -> ProxyOptions<'a> { proxy } +/// +pub fn add_remote( + repo_path: &RepoPath, + name: &str, + url: &str, +) -> Result<()> { + let repo = repo(repo_path)?; + repo.remote(name, url)?; + Ok(()) +} + +/// +pub fn rename_remote( + repo_path: &RepoPath, + name: &str, + new_name: &str, +) -> Result<()> { + let repo = repo(repo_path)?; + repo.remote_rename(name, new_name)?; + Ok(()) +} + +/// +pub fn update_remote_url( + repo_path: &RepoPath, + name: &str, + new_url: &str, +) -> Result<()> { + let repo = repo(repo_path)?; + repo.remote_set_url(name, new_url)?; + Ok(()) +} + +/// +pub fn delete_remote( + repo_path: &RepoPath, + remote_name: &str, +) -> Result<()> { + let repo = repo(repo_path)?; + repo.remote_delete(remote_name)?; + Ok(()) +} + +/// +pub fn validate_remote_name(name: &str) -> bool { + Remote::is_valid_name(name) +} + /// pub fn get_remotes(repo_path: &RepoPath) -> Result> { scope_time!("get_remotes"); @@ -44,6 +94,20 @@ pub fn get_remotes(repo_path: &RepoPath) -> Result> { Ok(remotes) } +/// +pub fn get_remote_url( + repo_path: &RepoPath, + remote_name: &str, +) -> Result> { + let repo = repo(repo_path)?; + let remote = repo.find_remote(remote_name)?.clone(); + let url = remote.url(); + if let Some(u) = url { + return Ok(Some(u.to_string())); + } + Ok(None) +} + /// tries to find origin or the only remote that is defined if any /// in case of multiple remotes and none named *origin* we fail pub fn get_default_remote(repo_path: &RepoPath) -> Result { diff --git a/src/app.rs b/src/app.rs index cd7f1aca62..45037f048f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -12,12 +12,14 @@ use crate::{ popups::{ AppOption, BlameFilePopup, BranchListPopup, CommitPopup, CompareCommitsPopup, ConfirmPopup, CreateBranchPopup, - ExternalEditorPopup, FetchPopup, FileRevlogPopup, - FuzzyFindPopup, HelpPopup, InspectCommitPopup, - LogSearchPopupPopup, MsgPopup, OptionsPopup, PullPopup, - PushPopup, PushTagsPopup, RenameBranchPopup, ResetPopup, - RevisionFilesPopup, StashMsgPopup, SubmodulesListPopup, - TagCommitPopup, TagListPopup, + 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, @@ -86,6 +88,10 @@ pub struct App { fetch_popup: FetchPopup, tag_commit_popup: TagCommitPopup, create_branch_popup: CreateBranchPopup, + create_remote_popup: CreateRemotePopup, + rename_remote_popup: RenameRemotePopup, + update_remote_url_popup: UpdateRemoteUrlPopup, + remotes_popup: RemoteListPopup, rename_branch_popup: RenameBranchPopup, select_branch_popup: BranchListPopup, options_popup: OptionsPopup, @@ -189,6 +195,10 @@ impl App { fetch_popup: FetchPopup::new(&env), tag_commit_popup: TagCommitPopup::new(&env), create_branch_popup: CreateBranchPopup::new(&env), + create_remote_popup: CreateRemotePopup::new(&env), + rename_remote_popup: RenameRemotePopup::new(&env), + update_remote_url_popup: UpdateRemoteUrlPopup::new(&env), + remotes_popup: RemoteListPopup::new(&env), rename_branch_popup: RenameBranchPopup::new(&env), select_branch_popup: BranchListPopup::new(&env), tags_popup: TagListPopup::new(&env), @@ -484,6 +494,10 @@ impl App { tag_commit_popup, reset_popup, create_branch_popup, + create_remote_popup, + rename_remote_popup, + update_remote_url_popup, + remotes_popup, rename_branch_popup, select_branch_popup, revision_files_popup, @@ -512,6 +526,10 @@ impl App { external_editor_popup, tag_commit_popup, select_branch_popup, + remotes_popup, + create_remote_popup, + rename_remote_popup, + update_remote_url_popup, submodule_popup, tags_popup, reset_popup, @@ -646,6 +664,9 @@ impl App { if flags.contains(NeedsUpdate::BRANCHES) { self.select_branch_popup.update_branches()?; } + if flags.contains(NeedsUpdate::REMOTES) { + self.remotes_popup.update_remotes()?; + } Ok(()) } @@ -727,7 +748,19 @@ impl App { InternalEvent::TagCommit(id) => { self.tag_commit_popup.open(id)?; } - + InternalEvent::CreateRemote => { + self.create_remote_popup.open()?; + } + InternalEvent::RenameRemote(cur_name) => { + self.rename_remote_popup.open(cur_name)?; + } + InternalEvent::UpdateRemoteUrl(remote_name, cur_url) => { + self.update_remote_url_popup + .open(remote_name, cur_url)?; + } + InternalEvent::ViewRemotes => { + self.remotes_popup.open()?; + } InternalEvent::CreateBranch => { self.create_branch_popup.open()?; } @@ -926,6 +959,9 @@ impl App { Action::DeleteRemoteBranch(branch_ref) => { self.delete_remote_branch(&branch_ref)?; } + Action::DeleteRemote(remote_name) => { + self.delete_remote(&remote_name); + } Action::DeleteTag(tag_name) => { self.delete_tag(tag_name)?; } @@ -1015,6 +1051,24 @@ impl App { Ok(()) } + fn delete_remote(&self, remote_name: &str) { + let res = + sync::delete_remote(&self.repo.borrow(), remote_name); + match res { + Ok(()) => { + self.queue.push(InternalEvent::Update( + NeedsUpdate::ALL | NeedsUpdate::REMOTES, + )); + } + Err(e) => { + log::error!("delete remote: {}", e,); + self.queue.push(InternalEvent::ShowErrorMsg( + format!("delete remote error:\n{e}",), + )); + } + } + } + fn commands(&self, force_all: bool) -> Vec { let mut res = Vec::new(); diff --git a/src/keys/key_list.rs b/src/keys/key_list.rs index a542ef938a..2903499587 100644 --- a/src/keys/key_list.rs +++ b/src/keys/key_list.rs @@ -118,6 +118,11 @@ pub struct KeysList { pub stage_unstage_item: GituiKeyEvent, pub tag_annotate: GituiKeyEvent, pub view_submodules: GituiKeyEvent, + pub view_remotes: GituiKeyEvent, + pub update_remote_name: GituiKeyEvent, + pub update_remote_url: GituiKeyEvent, + pub add_remote: GituiKeyEvent, + pub delete_remote: GituiKeyEvent, pub view_submodule_parent: GituiKeyEvent, pub update_submodule: GituiKeyEvent, pub commit_history_next: GituiKeyEvent, @@ -210,6 +215,11 @@ impl Default for KeysList { stage_unstage_item: GituiKeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), tag_annotate: GituiKeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL), view_submodules: GituiKeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT), + view_remotes: GituiKeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL), + update_remote_name: GituiKeyEvent::new(KeyCode::Char('n'),KeyModifiers::NONE), + update_remote_url: GituiKeyEvent::new(KeyCode::Char('u'),KeyModifiers::NONE), + add_remote: GituiKeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE), + delete_remote: GituiKeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE), view_submodule_parent: GituiKeyEvent::new(KeyCode::Char('p'), KeyModifiers::empty()), update_submodule: GituiKeyEvent::new(KeyCode::Char('u'), KeyModifiers::empty()), commit_history_next: GituiKeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL), diff --git a/src/popups/branchlist.rs b/src/popups/branchlist.rs index ba53239664..9eb5c57e09 100644 --- a/src/popups/branchlist.rs +++ b/src/popups/branchlist.rs @@ -112,111 +112,7 @@ impl Component for BranchListPopup { out.clear(); } - let selection_is_cur_branch = - self.selection_is_cur_branch(); - - out.push(CommandInfo::new( - strings::commands::scroll(&self.key_config), - true, - true, - )); - - out.push(CommandInfo::new( - strings::commands::close_popup(&self.key_config), - true, - true, - )); - - out.push(CommandInfo::new( - strings::commands::commit_details_open( - &self.key_config, - ), - true, - true, - )); - - out.push(CommandInfo::new( - strings::commands::compare_with_head( - &self.key_config, - ), - !selection_is_cur_branch, - true, - )); - - out.push(CommandInfo::new( - strings::commands::toggle_branch_popup( - &self.key_config, - self.local, - ), - true, - true, - )); - - out.push(CommandInfo::new( - strings::commands::select_branch_popup( - &self.key_config, - ), - !selection_is_cur_branch && self.valid_selection(), - true, - )); - - out.push(CommandInfo::new( - strings::commands::open_branch_create_popup( - &self.key_config, - ), - true, - self.local, - )); - - out.push(CommandInfo::new( - strings::commands::delete_branch_popup( - &self.key_config, - ), - !selection_is_cur_branch, - true, - )); - - out.push(CommandInfo::new( - strings::commands::merge_branch_popup( - &self.key_config, - ), - !selection_is_cur_branch, - true, - )); - - out.push(CommandInfo::new( - strings::commands::branch_popup_rebase( - &self.key_config, - ), - !selection_is_cur_branch, - true, - )); - - out.push(CommandInfo::new( - strings::commands::rename_branch_popup( - &self.key_config, - ), - true, - self.local, - )); - - out.push(CommandInfo::new( - strings::commands::fetch_remotes(&self.key_config), - self.has_remotes, - true, - )); - - out.push(CommandInfo::new( - strings::commands::find_branch(&self.key_config), - true, - true, - )); - - out.push(CommandInfo::new( - strings::commands::reset_branch(&self.key_config), - self.valid_selection(), - true, - )); + self.add_commands_internal(out); } visibility_blocking(self) } @@ -294,6 +190,9 @@ impl Component for BranchListPopup { && self.has_remotes { self.queue.push(InternalEvent::FetchRemotes); + } else if key_match(e, self.key_config.keys.view_remotes) + { + self.queue.push(InternalEvent::ViewRemotes); } else if key_match(e, self.key_config.keys.reset_branch) { if let Some(commit_id) = self.get_selected_commit() { @@ -776,4 +675,103 @@ impl BranchListPopup { }, )); } + + fn add_commands_internal(&self, out: &mut Vec) { + let selection_is_cur_branch = self.selection_is_cur_branch(); + + out.push(CommandInfo::new( + strings::commands::scroll(&self.key_config), + true, + true, + )); + + out.push(CommandInfo::new( + strings::commands::close_popup(&self.key_config), + true, + true, + )); + + out.push(CommandInfo::new( + strings::commands::commit_details_open(&self.key_config), + true, + true, + )); + + out.push(CommandInfo::new( + strings::commands::compare_with_head(&self.key_config), + !selection_is_cur_branch, + true, + )); + + out.push(CommandInfo::new( + strings::commands::toggle_branch_popup( + &self.key_config, + self.local, + ), + true, + true, + )); + + out.push(CommandInfo::new( + strings::commands::select_branch_popup(&self.key_config), + !selection_is_cur_branch && self.valid_selection(), + true, + )); + + out.push(CommandInfo::new( + strings::commands::open_branch_create_popup( + &self.key_config, + ), + true, + self.local, + )); + + out.push(CommandInfo::new( + strings::commands::delete_branch_popup(&self.key_config), + !selection_is_cur_branch, + true, + )); + + out.push(CommandInfo::new( + strings::commands::merge_branch_popup(&self.key_config), + !selection_is_cur_branch, + true, + )); + + out.push(CommandInfo::new( + strings::commands::branch_popup_rebase(&self.key_config), + !selection_is_cur_branch, + true, + )); + + out.push(CommandInfo::new( + strings::commands::rename_branch_popup(&self.key_config), + true, + self.local, + )); + + out.push(CommandInfo::new( + strings::commands::fetch_remotes(&self.key_config), + self.has_remotes, + true, + )); + + out.push(CommandInfo::new( + strings::commands::find_branch(&self.key_config), + true, + true, + )); + + out.push(CommandInfo::new( + strings::commands::reset_branch(&self.key_config), + self.valid_selection(), + true, + )); + + out.push(CommandInfo::new( + strings::commands::view_remotes(&self.key_config), + true, + self.has_remotes, + )); + } } diff --git a/src/popups/confirm.rs b/src/popups/confirm.rs index cb6f731268..9910a321f3 100644 --- a/src/popups/confirm.rs +++ b/src/popups/confirm.rs @@ -168,6 +168,10 @@ impl ConfirmPopup { branch_ref, ), ), + Action::DeleteRemote(remote_name)=>( + strings::confirm_title_delete_remote(&self.key_config), + strings::confirm_msg_delete_remote(&self.key_config,remote_name), + ), Action::DeleteTag(tag_name) => ( strings::confirm_title_delete_tag( &self.key_config, diff --git a/src/popups/create_remote.rs b/src/popups/create_remote.rs new file mode 100644 index 0000000000..e1681adefc --- /dev/null +++ b/src/popups/create_remote.rs @@ -0,0 +1,211 @@ +use anyhow::Result; +use asyncgit::sync::{self, validate_remote_name, RepoPathRef}; +use crossterm::event::Event; +use easy_cast::Cast; +use ratatui::{widgets::Paragraph, Frame}; + +use crate::{ + app::Environment, + components::{ + visibility_blocking, CommandBlocking, CommandInfo, Component, + DrawableComponent, EventState, InputType, TextInputComponent, + }, + keys::{key_match, SharedKeyConfig}, + queue::{InternalEvent, NeedsUpdate, Queue}, + strings, + ui::style::SharedTheme, +}; + +#[derive(Default)] +enum State { + #[default] + Name, + Url { + name: String, + }, +} + +pub struct CreateRemotePopup { + repo: RepoPathRef, + input: TextInputComponent, + queue: Queue, + key_config: SharedKeyConfig, + state: State, + theme: SharedTheme, +} + +impl DrawableComponent for CreateRemotePopup { + fn draw( + &self, + f: &mut ratatui::Frame, + rect: ratatui::prelude::Rect, + ) -> anyhow::Result<()> { + if self.is_visible() { + self.input.draw(f, rect)?; + self.draw_warnings(f); + } + Ok(()) + } +} + +impl Component for CreateRemotePopup { + fn commands( + &self, + out: &mut Vec, + force_all: bool, + ) -> CommandBlocking { + if self.is_visible() || force_all { + self.input.commands(out, force_all); + + out.push(CommandInfo::new( + strings::commands::remote_confirm_name_msg( + &self.key_config, + ), + true, + true, + )); + } + visibility_blocking(self) + } + + fn event( + &mut self, + ev: &crossterm::event::Event, + ) -> Result { + if self.is_visible() { + if self.input.event(ev)?.is_consumed() { + return Ok(EventState::Consumed); + } + + if let Event::Key(e) = ev { + if key_match(e, self.key_config.keys.enter) { + self.handle_submit(); + } + + return Ok(EventState::Consumed); + } + } + Ok(EventState::NotConsumed) + } + + fn is_visible(&self) -> bool { + self.input.is_visible() + } + + fn hide(&mut self) { + self.input.hide(); + } + + fn show(&mut self) -> Result<()> { + self.input.clear(); + self.input.set_title( + strings::create_remote_popup_title_name(&self.key_config), + ); + self.input.set_default_msg( + strings::create_remote_popup_msg_name(&self.key_config), + ); + + self.input.show()?; + + Ok(()) + } +} + +impl CreateRemotePopup { + pub fn new(env: &Environment) -> Self { + Self { + repo: env.repo.clone(), + queue: env.queue.clone(), + input: TextInputComponent::new(env, "", "", true) + .with_input_type(InputType::Singleline), + key_config: env.key_config.clone(), + state: State::Name, + theme: env.theme.clone(), + } + } + + pub fn open(&mut self) -> Result<()> { + self.state = State::Name; + self.input.clear(); + self.show()?; + + Ok(()) + } + + fn draw_warnings(&self, f: &mut Frame) { + let remote_name = match self.state { + State::Name => self.input.get_text(), + State::Url { .. } => return, + }; + + if !remote_name.is_empty() { + let valid = validate_remote_name(remote_name); + + if !valid { + let msg = strings::remote_name_invalid(); + let msg_length: u16 = msg.len().cast(); + let w = Paragraph::new(msg) + .style(self.theme.text_danger()); + + let rect = { + let mut rect = self.input.get_area(); + rect.y += rect.height.saturating_sub(1); + rect.height = 1; + let offset = + rect.width.saturating_sub(msg_length + 1); + rect.width = + rect.width.saturating_sub(offset + 1); + rect.x += offset; + + rect + }; + + f.render_widget(w, rect); + } + } + } + + fn handle_submit(&mut self) { + match &self.state { + State::Name => { + self.input.clear(); + self.input.set_title( + strings::create_remote_popup_title_url( + &self.key_config, + ), + ); + self.input.set_default_msg( + strings::create_remote_popup_msg_url( + &self.key_config, + ), + ); + self.state = State::Url { + name: self.input.get_text().to_string(), + }; + } + State::Url { name } => { + let res = sync::add_remote( + &self.repo.borrow(), + name, + self.input.get_text(), + ); + + match res { + Ok(()) => { + self.queue.push(InternalEvent::Update( + NeedsUpdate::ALL | NeedsUpdate::REMOTES, + )); + } + Err(e) => { + log::error!("create remote: {}", e,); + self.queue.push(InternalEvent::ShowErrorMsg( + format!("create remote error:\n{e}",), + )); + } + } + + self.hide(); + } + }; + } +} diff --git a/src/popups/mod.rs b/src/popups/mod.rs index 2216461ad7..cb3ae1af74 100644 --- a/src/popups/mod.rs +++ b/src/popups/mod.rs @@ -4,6 +4,7 @@ mod commit; mod compare_commits; mod confirm; mod create_branch; +mod create_remote; mod externaleditor; mod fetch; mod file_revlog; @@ -16,13 +17,16 @@ mod options; mod pull; mod push; mod push_tags; +mod remotelist; mod rename_branch; +mod rename_remote; mod reset; mod revision_files; mod stashmsg; mod submodules; mod tag_commit; mod taglist; +mod update_remote_url; pub use blame_file::{BlameFileOpen, BlameFilePopup}; pub use branchlist::BranchListPopup; @@ -30,6 +34,7 @@ pub use commit::CommitPopup; pub use compare_commits::CompareCommitsPopup; pub use confirm::ConfirmPopup; pub use create_branch::CreateBranchPopup; +pub use create_remote::CreateRemotePopup; pub use externaleditor::ExternalEditorPopup; pub use fetch::FetchPopup; pub use file_revlog::{FileRevOpen, FileRevlogPopup}; @@ -42,13 +47,16 @@ pub use options::{AppOption, OptionsPopup}; pub use pull::PullPopup; pub use push::PushPopup; pub use push_tags::PushTagsPopup; +pub use remotelist::RemoteListPopup; pub use rename_branch::RenameBranchPopup; +pub use rename_remote::RenameRemotePopup; pub use reset::ResetPopup; pub use revision_files::{FileTreeOpen, RevisionFilesPopup}; pub use stashmsg::StashMsgPopup; pub use submodules::SubmodulesListPopup; pub use tag_commit::TagCommitPopup; pub use taglist::TagListPopup; +pub use update_remote_url::UpdateRemoteUrlPopup; use crate::ui::style::Theme; use ratatui::{ diff --git a/src/popups/remotelist.rs b/src/popups/remotelist.rs new file mode 100644 index 0000000000..409f44714d --- /dev/null +++ b/src/popups/remotelist.rs @@ -0,0 +1,475 @@ +use std::cell::Cell; + +use asyncgit::sync::{get_remote_url, get_remotes, RepoPathRef}; +use ratatui::{ + layout::{ + Alignment, Constraint, Direction, Layout, Margin, Rect, + }, + text::{Line, Span, Text}, + widgets::{Block, BorderType, Borders, Clear, Paragraph, Wrap}, + Frame, +}; +use unicode_truncate::UnicodeTruncateStr; + +use crate::{ + app::Environment, + components::{ + visibility_blocking, CommandBlocking, CommandInfo, Component, + DrawableComponent, EventState, ScrollType, VerticalScroll, + }, + keys::{key_match, SharedKeyConfig}, + queue::{Action, InternalEvent, Queue}, + strings, + ui::{self, style::SharedTheme, Size}, +}; +use anyhow::Result; +use crossterm::event::{Event, KeyEvent}; + +pub struct RemoteListPopup { + remote_names: Vec, + repo: RepoPathRef, + visible: bool, + current_height: Cell, + queue: Queue, + selection: u16, + scroll: VerticalScroll, + theme: SharedTheme, + key_config: SharedKeyConfig, +} + +impl DrawableComponent for RemoteListPopup { + fn draw(&self, f: &mut Frame, rect: Rect) -> Result<()> { + if self.is_visible() { + const PERCENT_SIZE: Size = Size::new(40, 30); + const MIN_SIZE: Size = Size::new(30, 20); + let area = ui::centered_rect( + PERCENT_SIZE.width, + PERCENT_SIZE.height, + rect, + ); + let area = ui::rect_inside(MIN_SIZE, rect.into(), area); + let area = area.intersection(rect); + f.render_widget(Clear, area); + f.render_widget( + Block::default() + .title(strings::POPUP_TITLE_REMOTES) + .border_type(BorderType::Thick) + .borders(Borders::ALL), + area, + ); + let area = area.inner(Margin { + vertical: 1, + horizontal: 1, + }); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(vec![ + Constraint::Min(1), + Constraint::Length(1), + Constraint::Length(2), + ]) + .split(area); + self.draw_remotes_list(f, chunks[0])?; + self.draw_separator(f, chunks[1]); + self.draw_selected_remote_details(f, chunks[2]); + } + Ok(()) + } +} + +impl Component for RemoteListPopup { + fn commands( + &self, + out: &mut Vec, + force_all: bool, + ) -> CommandBlocking { + if self.is_visible() || force_all { + out.push(CommandInfo::new( + strings::commands::scroll(&self.key_config), + true, + true, + )); + + out.push(CommandInfo::new( + strings::commands::close_popup(&self.key_config), + true, + self.is_visible(), + )); + + out.push(CommandInfo::new( + strings::commands::update_remote_name( + &self.key_config, + ), + true, + self.valid_selection(), + )); + + out.push(CommandInfo::new( + strings::commands::update_remote_url( + &self.key_config, + ), + true, + self.valid_selection(), + )); + + out.push(CommandInfo::new( + strings::commands::create_remote(&self.key_config), + true, + self.valid_selection(), + )); + + out.push(CommandInfo::new( + strings::commands::delete_remote_popup( + &self.key_config, + ), + true, + self.valid_selection(), + )); + } + visibility_blocking(self) + } + + fn event(&mut self, ev: &Event) -> Result { + if !self.visible { + return Ok(EventState::NotConsumed); + } + + if let Event::Key(e) = ev { + if self.move_event(e)?.is_consumed() { + return Ok(EventState::Consumed); + } else if key_match(e, self.key_config.keys.add_remote) { + self.queue.push(InternalEvent::CreateRemote); + } else if key_match(e, self.key_config.keys.delete_remote) + && self.valid_selection() + { + self.delete_remote(); + } else if key_match( + e, + self.key_config.keys.update_remote_name, + ) { + self.rename_remote(); + } else if key_match( + e, + self.key_config.keys.update_remote_url, + ) { + self.update_remote_url(); + } + } + Ok(EventState::Consumed) + } + + fn is_visible(&self) -> bool { + self.visible + } + + fn hide(&mut self) { + self.visible = false; + } + + fn show(&mut self) -> Result<()> { + self.visible = true; + Ok(()) + } +} + +impl RemoteListPopup { + pub fn new(env: &Environment) -> Self { + Self { + remote_names: Vec::new(), + repo: env.repo.clone(), + visible: false, + scroll: VerticalScroll::new(), + theme: env.theme.clone(), + key_config: env.key_config.clone(), + queue: env.queue.clone(), + current_height: Cell::new(0), + selection: 0, + } + } + + fn move_event(&mut self, e: &KeyEvent) -> Result { + if key_match(e, self.key_config.keys.exit_popup) { + self.hide(); + } else if key_match(e, self.key_config.keys.move_down) { + return self + .move_selection(ScrollType::Up) + .map(Into::into); + } else if key_match(e, self.key_config.keys.move_up) { + return self + .move_selection(ScrollType::Down) + .map(Into::into); + } else if key_match(e, self.key_config.keys.page_down) { + return self + .move_selection(ScrollType::PageDown) + .map(Into::into); + } else if key_match(e, self.key_config.keys.page_up) { + return self + .move_selection(ScrollType::PageUp) + .map(Into::into); + } else if key_match(e, self.key_config.keys.home) { + return self + .move_selection(ScrollType::Home) + .map(Into::into); + } else if key_match(e, self.key_config.keys.end) { + return self + .move_selection(ScrollType::End) + .map(Into::into); + } + Ok(EventState::NotConsumed) + } + + /// + pub fn open(&mut self) -> Result<()> { + self.show()?; + self.update_remotes()?; + Ok(()) + } + + fn get_text( + &self, + theme: &SharedTheme, + width_available: u16, + height: usize, + ) -> Text { + const THREE_DOTS: &str = "..."; + const THREE_DOTS_LENGTH: usize = THREE_DOTS.len(); // "..." + + let name_length: usize = (width_available as usize) + .saturating_sub(THREE_DOTS_LENGTH); + + Text::from( + self.remote_names + .iter() + .skip(self.scroll.get_top()) + .take(height) + .enumerate() + .map(|(i, remote)| { + let selected = (self.selection as usize + - self.scroll.get_top()) + == i; + let mut remote_name = remote.clone(); + if remote_name.len() + > name_length + .saturating_sub(THREE_DOTS_LENGTH) + { + remote_name = remote_name + .unicode_truncate( + name_length.saturating_sub( + THREE_DOTS_LENGTH, + ), + ) + .0 + .to_string(); + remote_name += THREE_DOTS; + } + let span_name = Span::styled( + format!("{remote_name:name_length$}"), + theme.text(true, selected), + ); + Line::from(vec![span_name]) + }) + .collect::>(), + ) + } + + fn draw_remotes_list( + &self, + f: &mut Frame, + r: Rect, + ) -> Result<()> { + let height_in_lines = r.height as usize; + self.current_height.set(height_in_lines.try_into()?); + + self.scroll.update( + self.selection as usize, + self.remote_names.len(), + height_in_lines, + ); + + f.render_widget( + Paragraph::new(self.get_text( + &self.theme, + r.width.saturating_add(1), + height_in_lines, + )) + .alignment(Alignment::Left), + r, + ); + + let mut r = r; + r.width += 1; + r.height += 2; + r.y = r.y.saturating_sub(1); + + self.scroll.draw(f, r, &self.theme); + + Ok(()) + } + + fn draw_separator(&self, f: &mut Frame, r: Rect) { + // Discard self argument because it is not needed. + let _ = self; + f.render_widget( + Block::default() + .title(strings::POPUP_SUBTITLE_REMOTES) + .border_type(BorderType::Plain) + .borders(Borders::TOP), + r, + ); + } + + fn draw_selected_remote_details(&self, f: &mut Frame, r: Rect) { + const THREE_DOTS: &str = "..."; + const THREE_DOTS_LENGTH: usize = THREE_DOTS.len(); // "..." + const REMOTE_NAME_LABEL: &str = "name: "; + const REMOTE_NAME_LABEL_LENGTH: usize = + REMOTE_NAME_LABEL.len(); + const REMOTE_URL_LABEL: &str = "url: "; + const REMOTE_URL_LABEL_LENGTH: usize = REMOTE_URL_LABEL.len(); + + let name_length: usize = (r.width.saturating_sub(1) as usize) + .saturating_sub(REMOTE_NAME_LABEL_LENGTH); + let url_length: usize = (r.width.saturating_sub(1) as usize) + .saturating_sub(REMOTE_URL_LABEL_LENGTH); + + let remote = + self.remote_names.get(usize::from(self.selection)); + if let Some(remote) = remote { + let mut remote_name = remote.clone(); + if remote_name.len() + > name_length.saturating_sub(THREE_DOTS_LENGTH) + { + remote_name = remote_name + .unicode_truncate( + name_length.saturating_sub(THREE_DOTS_LENGTH), + ) + .0 + .to_string(); + remote_name += THREE_DOTS; + } + let mut lines = Vec::::new(); + lines.push(Line::from(Span::styled( + format!( + "{REMOTE_NAME_LABEL}{remote_name:name_length$}" + ), + self.theme.text(true, false), + ))); + let remote_url = + get_remote_url(&self.repo.borrow(), remote); + if let Ok(Some(mut remote_url)) = remote_url { + if remote_url.len() + > url_length.saturating_sub(THREE_DOTS_LENGTH) + { + remote_url = remote_url + .chars() + .skip( + remote_url.len() + - url_length.saturating_sub( + THREE_DOTS_LENGTH, + ), + ) + .collect::(); + remote_url = format!("{THREE_DOTS}{remote_url}"); + } + lines.push(Line::from(Span::styled( + format!( + "{REMOTE_URL_LABEL}{remote_url:url_length$}" + ), + self.theme.text(true, false), + ))); + } + f.render_widget( + Paragraph::new(Text::from(lines)) + .alignment(Alignment::Left) + .wrap(Wrap { trim: true }), + r, + ); + + let mut r = r; + r.width += 1; + r.height += 2; + r.y = r.y.saturating_sub(1); + } + } + + /// + fn move_selection(&mut self, scroll: ScrollType) -> Result { + let new_selection = match scroll { + ScrollType::Up => self.selection.saturating_add(1), + ScrollType::Down => self.selection.saturating_sub(1), + ScrollType::PageDown => self + .selection + .saturating_add(self.current_height.get()), + ScrollType::PageUp => self + .selection + .saturating_sub(self.current_height.get()), + ScrollType::Home => 0, + ScrollType::End => { + let num_branches: u16 = + self.remote_names.len().try_into()?; + num_branches.saturating_sub(1) + } + }; + + self.set_selection(new_selection)?; + + Ok(true) + } + + fn valid_selection(&self) -> bool { + !self.remote_names.is_empty() + && self.remote_names.len() >= self.selection as usize + } + + fn set_selection(&mut self, selection: u16) -> Result<()> { + let num_remotes: u16 = self.remote_names.len().try_into()?; + let num_remotes = num_remotes.saturating_sub(1); + + let selection = if selection > num_remotes { + num_remotes + } else { + selection + }; + + self.selection = selection; + + Ok(()) + } + + pub fn update_remotes(&mut self) -> Result<()> { + if self.is_visible() { + self.remote_names = get_remotes(&self.repo.borrow())?; + self.set_selection(self.selection)?; + } + Ok(()) + } + + fn delete_remote(&self) { + let remote_name = + self.remote_names[self.selection as usize].clone(); + + self.queue.push(InternalEvent::ConfirmAction( + Action::DeleteRemote(remote_name), + )); + } + + fn rename_remote(&self) { + let remote_name = + self.remote_names[self.selection as usize].clone(); + + self.queue.push(InternalEvent::RenameRemote(remote_name)); + } + + fn update_remote_url(&self) { + let remote_name = + self.remote_names[self.selection as usize].clone(); + let remote_url = + get_remote_url(&self.repo.borrow(), &remote_name); + if let Ok(Some(url)) = remote_url { + self.queue.push(InternalEvent::UpdateRemoteUrl( + remote_name, + url, + )); + } + } +} diff --git a/src/popups/rename_remote.rs b/src/popups/rename_remote.rs new file mode 100644 index 0000000000..fc88389a84 --- /dev/null +++ b/src/popups/rename_remote.rs @@ -0,0 +1,176 @@ +use anyhow::Result; +use asyncgit::sync::{self, RepoPathRef}; +use crossterm::event::Event; +use easy_cast::Cast; +use ratatui::{layout::Rect, widgets::Paragraph, Frame}; + +use crate::{ + app::Environment, + components::{ + visibility_blocking, CommandBlocking, CommandInfo, Component, + DrawableComponent, EventState, InputType, TextInputComponent, + }, + keys::{key_match, SharedKeyConfig}, + queue::{InternalEvent, NeedsUpdate, Queue}, + strings, + ui::style::SharedTheme, +}; + +pub struct RenameRemotePopup { + repo: RepoPathRef, + input: TextInputComponent, + theme: SharedTheme, + key_config: SharedKeyConfig, + queue: Queue, + initial_name: Option, +} + +impl DrawableComponent for RenameRemotePopup { + fn draw(&self, f: &mut Frame, rect: Rect) -> Result<()> { + if self.is_visible() { + self.input.draw(f, rect)?; + self.draw_warnings(f); + } + Ok(()) + } +} + +impl Component for RenameRemotePopup { + fn commands( + &self, + out: &mut Vec, + force_all: bool, + ) -> CommandBlocking { + if self.is_visible() || force_all { + self.input.commands(out, force_all); + + out.push(CommandInfo::new( + strings::commands::remote_confirm_name_msg( + &self.key_config, + ), + true, + true, + )); + } + visibility_blocking(self) + } + + fn event(&mut self, ev: &Event) -> Result { + if self.is_visible() { + if self.input.event(ev)?.is_consumed() { + return Ok(EventState::Consumed); + } + + if let Event::Key(e) = ev { + if key_match(e, self.key_config.keys.enter) { + self.rename_remote(); + } + + return Ok(EventState::Consumed); + } + } + Ok(EventState::NotConsumed) + } + + fn is_visible(&self) -> bool { + self.input.is_visible() + } + + fn hide(&mut self) { + self.input.hide(); + } + + fn show(&mut self) -> Result<()> { + self.input.show()?; + + Ok(()) + } +} + +impl RenameRemotePopup { + /// + pub fn new(env: &Environment) -> Self { + Self { + repo: env.repo.clone(), + input: TextInputComponent::new( + env, + &strings::rename_remote_popup_title(&env.key_config), + &strings::rename_remote_popup_msg(&env.key_config), + true, + ) + .with_input_type(InputType::Singleline), + theme: env.theme.clone(), + key_config: env.key_config.clone(), + queue: env.queue.clone(), + initial_name: None, + } + } + + /// + pub fn open(&mut self, cur_name: String) -> Result<()> { + self.input.set_text(cur_name.clone()); + self.initial_name = Some(cur_name); + self.show()?; + + Ok(()) + } + + fn draw_warnings(&self, f: &mut Frame) { + let current_text = self.input.get_text(); + + if !current_text.is_empty() { + let valid = sync::validate_remote_name(current_text); + + if !valid { + let msg = strings::branch_name_invalid(); + let msg_length: u16 = msg.len().cast(); + let w = Paragraph::new(msg) + .style(self.theme.text_danger()); + + let rect = { + let mut rect = self.input.get_area(); + rect.y += rect.height.saturating_sub(1); + rect.height = 1; + let offset = + rect.width.saturating_sub(msg_length + 1); + rect.width = + rect.width.saturating_sub(offset + 1); + rect.x += offset; + + rect + }; + + f.render_widget(w, rect); + } + } + } + + /// + pub fn rename_remote(&mut self) { + if let Some(init_name) = &self.initial_name { + if init_name != self.input.get_text() { + let res = sync::rename_remote( + &self.repo.borrow(), + init_name, + self.input.get_text(), + ); + match res { + Ok(()) => { + self.queue.push(InternalEvent::Update( + NeedsUpdate::ALL | NeedsUpdate::REMOTES, + )); + } + Err(e) => { + log::error!("rename remote: {}", e,); + self.queue.push(InternalEvent::ShowErrorMsg( + format!("rename remote error:\n{e}",), + )); + } + } + } + } + self.input.clear(); + self.initial_name = None; + self.hide(); + } +} diff --git a/src/popups/update_remote_url.rs b/src/popups/update_remote_url.rs new file mode 100644 index 0000000000..7a6956bb3e --- /dev/null +++ b/src/popups/update_remote_url.rs @@ -0,0 +1,152 @@ +use anyhow::Result; +use asyncgit::sync::{self, RepoPathRef}; +use crossterm::event::Event; + +use crate::{ + app::Environment, + components::{ + visibility_blocking, CommandBlocking, CommandInfo, Component, + DrawableComponent, EventState, InputType, TextInputComponent, + }, + keys::{key_match, SharedKeyConfig}, + queue::{InternalEvent, NeedsUpdate, Queue}, + strings, +}; + +pub struct UpdateRemoteUrlPopup { + repo: RepoPathRef, + input: TextInputComponent, + key_config: SharedKeyConfig, + queue: Queue, + remote_name: Option, + initial_url: Option, +} + +impl DrawableComponent for UpdateRemoteUrlPopup { + fn draw( + &self, + f: &mut ratatui::Frame, + rect: ratatui::prelude::Rect, + ) -> anyhow::Result<()> { + if self.is_visible() { + self.input.draw(f, rect)?; + } + Ok(()) + } +} + +impl Component for UpdateRemoteUrlPopup { + fn commands( + &self, + out: &mut Vec, + force_all: bool, + ) -> CommandBlocking { + if self.is_visible() || force_all { + self.input.commands(out, force_all); + + out.push(CommandInfo::new( + strings::commands::remote_confirm_url_msg( + &self.key_config, + ), + true, + true, + )); + } + visibility_blocking(self) + } + + fn event(&mut self, ev: &Event) -> Result { + if self.is_visible() { + if self.input.event(ev)?.is_consumed() { + return Ok(EventState::Consumed); + } + + if let Event::Key(e) = ev { + if key_match(e, self.key_config.keys.enter) { + self.update_remote_url(); + } + + return Ok(EventState::Consumed); + } + } + Ok(EventState::NotConsumed) + } + + fn is_visible(&self) -> bool { + self.input.is_visible() + } + + fn hide(&mut self) { + self.input.hide(); + } + + fn show(&mut self) -> Result<()> { + self.input.show()?; + + Ok(()) + } +} + +impl UpdateRemoteUrlPopup { + pub fn new(env: &Environment) -> Self { + Self { + repo: env.repo.clone(), + input: TextInputComponent::new( + env, + &strings::update_remote_url_popup_title( + &env.key_config, + ), + &strings::update_remote_url_popup_msg( + &env.key_config, + ), + true, + ) + .with_input_type(InputType::Singleline), + key_config: env.key_config.clone(), + queue: env.queue.clone(), + initial_url: None, + remote_name: None, + } + } + + /// + pub fn open( + &mut self, + remote_name: String, + cur_url: String, + ) -> Result<()> { + self.input.set_text(cur_url.clone()); + self.remote_name = Some(remote_name); + self.initial_url = Some(cur_url); + self.show()?; + + Ok(()) + } + + /// + pub fn update_remote_url(&mut self) { + if let Some(remote_name) = &self.remote_name { + let res = sync::update_remote_url( + &self.repo.borrow(), + remote_name, + self.input.get_text(), + ); + match res { + Ok(()) => { + self.queue.push(InternalEvent::Update( + NeedsUpdate::ALL | NeedsUpdate::REMOTES, + )); + } + Err(e) => { + log::error!("update remote url: {}", e,); + self.queue.push(InternalEvent::ShowErrorMsg( + format!("update remote url error:\n{e}",), + )); + } + } + } + self.input.clear(); + self.initial_url = None; + self.hide(); + } +} diff --git a/src/queue.rs b/src/queue.rs index 9ee7830bdd..44268a851d 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -28,6 +28,8 @@ bitflags! { const COMMANDS = 0b100; /// branches have changed const BRANCHES = 0b1000; + /// Remotes have changed + const REMOTES = 0b1001; } } @@ -48,6 +50,7 @@ pub enum Action { DeleteRemoteBranch(String), DeleteTag(String), DeleteRemoteTag(String, String), + DeleteRemote(String), ForcePush(String, bool), PullMerge { incoming: usize, rebase: bool }, AbortMerge, @@ -109,6 +112,10 @@ pub enum InternalEvent { /// CreateBranch, /// + RenameRemote(String), + /// + UpdateRemoteUrl(String, String), + /// RenameBranch(String, String), /// SelectBranch, @@ -139,6 +146,10 @@ pub enum InternalEvent { /// ViewSubmodules, /// + ViewRemotes, + /// + CreateRemote, + /// OpenRepo { path: PathBuf }, /// OpenResetPopup(CommitId), diff --git a/src/strings.rs b/src/strings.rs index 70ca9e3e8f..c4cff10f70 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -30,6 +30,8 @@ pub static PUSH_TAGS_STATES_PUSHING: &str = "pushing"; pub static PUSH_TAGS_STATES_DONE: &str = "done"; pub static POPUP_TITLE_SUBMODULES: &str = "Submodules"; +pub static POPUP_TITLE_REMOTES: &str = "Remotes"; +pub static POPUP_SUBTITLE_REMOTES: &str = "Details"; pub static POPUP_TITLE_FUZZY_FIND: &str = "Fuzzy Finder"; pub static POPUP_TITLE_LOG_SEARCH: &str = "Search"; @@ -251,6 +253,17 @@ pub fn confirm_title_delete_remote_branch( ) -> String { "Delete Remote Branch".to_string() } +pub fn confirm_title_delete_remote( + _key_config: &SharedKeyConfig, +) -> String { + "Delete Remote".to_string() +} +pub fn confirm_msg_delete_remote( + _key_config: &SharedKeyConfig, + remote_name: &str, +) -> String { + format!("Confirm deleting remote \"{remote_name}\"") +} pub fn confirm_msg_delete_remote_branch( _key_config: &SharedKeyConfig, branch_ref: &str, @@ -339,6 +352,49 @@ pub fn create_branch_popup_msg( ) -> String { "type branch name".to_string() } +pub fn rename_remote_popup_title( + _key_config: &SharedKeyConfig, +) -> String { + "Rename remote".to_string() +} +pub fn rename_remote_popup_msg( + _key_config: &SharedKeyConfig, +) -> String { + "new remote name".to_string() +} +pub fn update_remote_url_popup_title( + _key_config: &SharedKeyConfig, +) -> String { + "Update url".to_string() +} +pub fn update_remote_url_popup_msg( + _key_config: &SharedKeyConfig, +) -> String { + "new remote url".to_string() +} +pub fn create_remote_popup_title_name( + _key_config: &SharedKeyConfig, +) -> String { + "Remote name".to_string() +} +pub fn create_remote_popup_title_url( + _key_config: &SharedKeyConfig, +) -> String { + "Remote url".to_string() +} +pub fn create_remote_popup_msg_name( + _key_config: &SharedKeyConfig, +) -> String { + "type remote name".to_string() +} +pub fn create_remote_popup_msg_url( + _key_config: &SharedKeyConfig, +) -> String { + "type remote url".to_string() +} +pub const fn remote_name_invalid() -> &'static str { + "[invalid name]" +} pub fn username_popup_title(_key_config: &SharedKeyConfig) -> String { "Username".to_string() } @@ -830,6 +886,99 @@ pub mod commands { ) } + pub fn view_remotes(key_config: &SharedKeyConfig) -> CommandText { + CommandText::new( + format!( + "Remotes [{}]", + key_config.get_hint(key_config.keys.view_remotes) + ), + "open remotes view", + CMD_GROUP_GENERAL, + ) + } + + pub fn update_remote_name( + key_config: &SharedKeyConfig, + ) -> CommandText { + CommandText::new( + format!( + "Edit name [{}]", + key_config + .get_hint(key_config.keys.update_remote_name) + ), + "updates a remote name", + CMD_GROUP_GENERAL, + ) + } + + pub fn update_remote_url( + key_config: &SharedKeyConfig, + ) -> CommandText { + CommandText::new( + format!( + "Edit url [{}]", + key_config + .get_hint(key_config.keys.update_remote_url) + ), + "updates a remote url", + CMD_GROUP_GENERAL, + ) + } + + pub fn create_remote( + key_config: &SharedKeyConfig, + ) -> CommandText { + CommandText::new( + format!( + "Add [{}]", + key_config.get_hint(key_config.keys.add_remote) + ), + "creates a new remote", + CMD_GROUP_GENERAL, + ) + } + + pub fn delete_remote_popup( + key_config: &SharedKeyConfig, + ) -> CommandText { + CommandText::new( + format!( + "Remove [{}]", + key_config.get_hint(key_config.keys.delete_remote), + ), + "remove a remote", + CMD_GROUP_BRANCHES, + ) + } + + pub fn remote_confirm_name_msg( + key_config: &SharedKeyConfig, + ) -> CommandText { + CommandText::new( + format!( + "Confirm name [{}]", + key_config.get_hint(key_config.keys.enter), + ), + "confirm remote name", + CMD_GROUP_BRANCHES, + ) + .hide_help() + } + + pub fn remote_confirm_url_msg( + key_config: &SharedKeyConfig, + ) -> CommandText { + CommandText::new( + format!( + "Confirm url [{}]", + key_config.get_hint(key_config.keys.enter), + ), + "confirm remote url", + CMD_GROUP_BRANCHES, + ) + .hide_help() + } + pub fn open_submodule( key_config: &SharedKeyConfig, ) -> CommandText {