Repository: gitui-org/gitui (language: rust)

Issue:
List Remotes Popup
Currently we can not list the available remotes, add, rename or remove them.

From the `BranchListPopup` we should allow opening a popup that provides that functionality.

Mockup of this popup:
```
┌Remotes─────┐
│origin      │
│foo         │
│..          │   
│Details─────│
│name: origin│
│url: git:...│
└────────────┘
Scroll [↑↓] Fetch [⇧F] Edit [e] Add [a] Remove [r]
```

Other popups are good ways for inspiration on how to do this e.g `SubmodulesListPopup`


Discussion / hints:
Hey, could I work on this?
Absolutely
Please join our discord for easier communication: https://discord.gg/d3nETBB4
Hey @ADTmux, just wondering if you are still working on this?
Same question here. Is anybody working on this as of now?
Nope. Feel free to claim it. Also join our discord for coordination if you like

Fix patch:
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<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> {
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<CommandInfo> {
 		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<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,
+		));
+	}
 }
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<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();
+			}
+		};
+	}
+}
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<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,
+			));
+		}
+	}
+}
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<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();
+	}
+}
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<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();
+	}
+}
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 {


Test patch: