Remotes popup (#2350)

Co-authored-by: extrawurst <mail@rusticorn.com>
Co-authored-by: extrawurst <776816+extrawurst@users.noreply.github.com>
This commit is contained in:
Robin 2024-09-18 16:31:21 +02:00 committed by GitHub
parent 2cbeeeda95
commit d4f9400e04
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1433 additions and 117 deletions

View File

@ -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

View File

@ -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',

View File

@ -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};

View File

@ -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<Vec<String>> {
scope_time!("get_remotes");
@ -44,6 +94,20 @@ pub fn get_remotes(repo_path: &RepoPath) -> Result<Vec<String>> {
Ok(remotes)
}
///
pub fn get_remote_url(
repo_path: &RepoPath,
remote_name: &str,
) -> Result<Option<String>> {
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<String> {

View File

@ -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<CommandInfo> {
let mut res = Vec::new();

View File

@ -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),

View File

@ -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<CommandInfo>) {
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,
));
}
}

View File

@ -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,

211
src/popups/create_remote.rs Normal file
View File

@ -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<CommandInfo>,
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<EventState> {
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();
}
};
}
}

View File

@ -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::{

475
src/popups/remotelist.rs Normal file
View File

@ -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<String>,
repo: RepoPathRef,
visible: bool,
current_height: Cell<u16>,
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<CommandInfo>,
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<EventState> {
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<EventState> {
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::<Vec<_>>(),
)
}
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::<Line>::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::<String>();
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<bool> {
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,
));
}
}
}

176
src/popups/rename_remote.rs Normal file
View File

@ -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<String>,
}
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<CommandInfo>,
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<EventState> {
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();
}
}

View File

@ -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<String>,
initial_url: Option<String>,
}
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<crate::components::CommandInfo>,
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<EventState> {
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();
}
}

View File

@ -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),

View File

@ -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 {