diff --git a/CHANGELOG.md b/CHANGELOG.md index ec053ea773..750c68f7ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * It is now possible to specity configuration options on the command line with he new `--config-toml` global option. +* (#469) `jj git` subcommands will prompt for credentials when + required for HTTPS remotes rather than failing. + ### Fixed bugs * `jj edit root` now fails gracefully. diff --git a/Cargo.lock b/Cargo.lock index 36f68e1e44..0b0ca33a50 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -730,6 +730,7 @@ dependencies = [ "predicates", "rand", "regex", + "rpassword", "serde", "tempfile", "test-case", @@ -1340,6 +1341,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b833d8d034ea094b1ea68aa6d5c740e0d04bad9d16568d08ba6f76823a114316" +[[package]] +name = "rpassword" +version = "7.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c9f5d2a0c3e2ea729ab3706d22217177770654c3ef5056b68b69d07332d3f5" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "ryu" version = "1.0.11" diff --git a/Cargo.toml b/Cargo.toml index d6d8176b7b..1797922137 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,7 @@ pest = "2.4.0" pest_derive = "2.4" rand = "0.8.5" regex = "1.6.0" +rpassword = "7.1.0" serde = { version = "1.0", features = ["derive"] } tempfile = "3.3.0" textwrap = "0.16.0" diff --git a/lib/src/git.rs b/lib/src/git.rs index 36ef447b26..aa23470146 100644 --- a/lib/src/git.rs +++ b/lib/src/git.rs @@ -15,9 +15,10 @@ use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::fs::OpenOptions; use std::io::{Read, Write}; +use std::path::PathBuf; use std::sync::Arc; -use git2::{Oid, RemoteCallbacks}; +use git2::Oid; use itertools::Itertools; use thiserror::Error; @@ -280,7 +281,7 @@ pub fn fetch( mut_repo: &mut MutableRepo, git_repo: &git2::Repository, remote_name: &str, - progress: Option<&mut dyn FnMut(&Progress)>, + callbacks: RemoteCallbacks<'_>, ) -> Result, GitFetchError> { let mut remote = git_repo @@ -298,7 +299,7 @@ pub fn fetch( let mut proxy_options = git2::ProxyOptions::new(); proxy_options.auto(); fetch_options.proxy_options(proxy_options); - let callbacks = create_remote_callbacks(progress); + let callbacks = callbacks.into_git(); fetch_options.remote_callbacks(callbacks); let refspec: &[&str] = &[]; remote.download(refspec, Some(&mut fetch_options))?; @@ -346,6 +347,7 @@ pub fn push_commit( // It's a blunt "force" option instead until git2-rs supports the "push negotiation" callback // (https://github.com/rust-lang/git2-rs/issues/733). force: bool, + callbacks: RemoteCallbacks<'_>, ) -> Result<(), GitPushError> { push_updates( git_repo, @@ -355,6 +357,7 @@ pub fn push_commit( force, new_target: Some(target.id().clone()), }], + callbacks, ) } @@ -371,6 +374,7 @@ pub fn push_updates( git_repo: &git2::Repository, remote_name: &str, updates: &[GitRefUpdate], + callbacks: RemoteCallbacks<'_>, ) -> Result<(), GitPushError> { let mut temp_refs = vec![]; let mut qualified_remote_refs = vec![]; @@ -396,7 +400,13 @@ pub fn push_updates( refspecs.push(format!(":{}", update.qualified_name)); } } - let result = push_refs(git_repo, remote_name, &qualified_remote_refs, &refspecs); + let result = push_refs( + git_repo, + remote_name, + &qualified_remote_refs, + &refspecs, + callbacks, + ); for mut temp_ref in temp_refs { // TODO: Figure out how to do the equivalent of absl::Cleanup for // temp_ref.delete(). @@ -417,6 +427,7 @@ fn push_refs( remote_name: &str, qualified_remote_refs: &[&str], refspecs: &[String], + callbacks: RemoteCallbacks<'_>, ) -> Result<(), GitPushError> { let mut remote = git_repo @@ -435,7 +446,7 @@ fn push_refs( let mut proxy_options = git2::ProxyOptions::new(); proxy_options.auto(); push_options.proxy_options(proxy_options); - let mut callbacks = create_remote_callbacks(None); + let mut callbacks = callbacks.into_git(); callbacks.push_update_reference(|refname, status| { // The status is Some if the ref update was rejected if status.is_none() { @@ -466,39 +477,67 @@ fn push_refs( } } -fn create_remote_callbacks(progress_cb: Option<&mut dyn FnMut(&Progress)>) -> RemoteCallbacks<'_> { - let mut callbacks = git2::RemoteCallbacks::new(); - if let Some(progress_cb) = progress_cb { - callbacks.transfer_progress(move |progress| { - progress_cb(&Progress { - bytes_downloaded: if progress.received_objects() < progress.total_objects() { - Some(progress.received_bytes() as u64) - } else { - None - }, - overall: (progress.indexed_objects() + progress.indexed_deltas()) as f32 - / (progress.total_objects() + progress.total_deltas()) as f32, +#[non_exhaustive] +#[derive(Default)] +#[allow(clippy::type_complexity)] +pub struct RemoteCallbacks<'a> { + pub progress: Option<&'a mut dyn FnMut(&Progress)>, + pub get_ssh_key: Option<&'a mut dyn FnMut(&str) -> Option>, + pub get_password: Option<&'a mut dyn FnMut(&str, &str) -> Option>, + pub get_username_password: Option<&'a mut dyn FnMut(&str) -> Option<(String, String)>>, +} + +impl<'a> RemoteCallbacks<'a> { + fn into_git(mut self) -> git2::RemoteCallbacks<'a> { + let mut callbacks = git2::RemoteCallbacks::new(); + if let Some(progress_cb) = self.progress { + callbacks.transfer_progress(move |progress| { + progress_cb(&Progress { + bytes_downloaded: if progress.received_objects() < progress.total_objects() { + Some(progress.received_bytes() as u64) + } else { + None + }, + overall: (progress.indexed_objects() + progress.indexed_deltas()) as f32 + / (progress.total_objects() + progress.total_deltas()) as f32, + }); + true }); - true - }); - } - // TODO: We should expose the callbacks to the caller instead -- the library - // crate shouldn't look in $HOME etc. - callbacks.credentials(|_url, username_from_url, allowed_types| { - if allowed_types.contains(git2::CredentialType::SSH_KEY) { - if std::env::var("SSH_AUTH_SOCK").is_ok() || std::env::var("SSH_AGENT_PID").is_ok() { - return git2::Cred::ssh_key_from_agent(username_from_url.unwrap()); - } - if let Ok(home_dir) = std::env::var("HOME") { - let key_path = std::path::Path::new(&home_dir).join(".ssh").join("id_rsa"); - if key_path.is_file() { - return git2::Cred::ssh_key(username_from_url.unwrap(), None, &key_path, None); + } + // TODO: We should expose the callbacks to the caller instead -- the library + // crate shouldn't read environment variables. + callbacks.credentials(move |url, username_from_url, allowed_types| { + if let Some(username) = username_from_url { + if allowed_types.contains(git2::CredentialType::SSH_KEY) { + if std::env::var("SSH_AUTH_SOCK").is_ok() + || std::env::var("SSH_AGENT_PID").is_ok() + { + return git2::Cred::ssh_key_from_agent(username); + } + if let Some(ref mut cb) = self.get_ssh_key { + if let Some(path) = cb(username) { + return git2::Cred::ssh_key(username, None, &path, None); + } + } + } + if allowed_types.contains(git2::CredentialType::USER_PASS_PLAINTEXT) { + if let Some(ref mut cb) = self.get_password { + if let Some(pw) = cb(url, username) { + return git2::Cred::userpass_plaintext(username, &pw); + } + } + } + } else if allowed_types.contains(git2::CredentialType::USER_PASS_PLAINTEXT) { + if let Some(ref mut cb) = self.get_username_password { + if let Some((username, pw)) = cb(url) { + return git2::Cred::userpass_plaintext(&username, &pw); + } } } - } - git2::Cred::default() - }); - callbacks + git2::Cred::default() + }); + callbacks + } } pub struct Progress { diff --git a/lib/tests/test_git.rs b/lib/tests/test_git.rs index f66d738e38..e48c8bf744 100644 --- a/lib/tests/test_git.rs +++ b/lib/tests/test_git.rs @@ -593,7 +593,13 @@ fn test_fetch_empty_repo() { let test_data = GitRepoData::create(); let mut tx = test_data.repo.start_transaction("test"); - let default_branch = git::fetch(tx.mut_repo(), &test_data.git_repo, "origin", None).unwrap(); + let default_branch = git::fetch( + tx.mut_repo(), + &test_data.git_repo, + "origin", + git::RemoteCallbacks::default(), + ) + .unwrap(); // No default branch and no refs assert_eq!(default_branch, None); assert_eq!(*tx.mut_repo().view().git_refs(), btreemap! {}); @@ -606,7 +612,13 @@ fn test_fetch_initial_commit() { let initial_git_commit = empty_git_commit(&test_data.origin_repo, "refs/heads/main", &[]); let mut tx = test_data.repo.start_transaction("test"); - let default_branch = git::fetch(tx.mut_repo(), &test_data.git_repo, "origin", None).unwrap(); + let default_branch = git::fetch( + tx.mut_repo(), + &test_data.git_repo, + "origin", + git::RemoteCallbacks::default(), + ) + .unwrap(); // No default branch because the origin repo's HEAD wasn't set assert_eq!(default_branch, None); let repo = tx.commit(); @@ -637,7 +649,13 @@ fn test_fetch_success() { let initial_git_commit = empty_git_commit(&test_data.origin_repo, "refs/heads/main", &[]); let mut tx = test_data.repo.start_transaction("test"); - git::fetch(tx.mut_repo(), &test_data.git_repo, "origin", None).unwrap(); + git::fetch( + tx.mut_repo(), + &test_data.git_repo, + "origin", + git::RemoteCallbacks::default(), + ) + .unwrap(); test_data.repo = tx.commit(); test_data.origin_repo.set_head("refs/heads/main").unwrap(); @@ -648,7 +666,13 @@ fn test_fetch_success() { ); let mut tx = test_data.repo.start_transaction("test"); - let default_branch = git::fetch(tx.mut_repo(), &test_data.git_repo, "origin", None).unwrap(); + let default_branch = git::fetch( + tx.mut_repo(), + &test_data.git_repo, + "origin", + git::RemoteCallbacks::default(), + ) + .unwrap(); // The default branch is "main" assert_eq!(default_branch, Some("main".to_string())); let repo = tx.commit(); @@ -679,7 +703,13 @@ fn test_fetch_prune_deleted_ref() { empty_git_commit(&test_data.git_repo, "refs/heads/main", &[]); let mut tx = test_data.repo.start_transaction("test"); - git::fetch(tx.mut_repo(), &test_data.git_repo, "origin", None).unwrap(); + git::fetch( + tx.mut_repo(), + &test_data.git_repo, + "origin", + git::RemoteCallbacks::default(), + ) + .unwrap(); // Test the setup assert!(tx.mut_repo().get_branch("main").is_some()); @@ -690,7 +720,13 @@ fn test_fetch_prune_deleted_ref() { .delete() .unwrap(); // After re-fetching, the branch should be deleted - git::fetch(tx.mut_repo(), &test_data.git_repo, "origin", None).unwrap(); + git::fetch( + tx.mut_repo(), + &test_data.git_repo, + "origin", + git::RemoteCallbacks::default(), + ) + .unwrap(); assert!(tx.mut_repo().get_branch("main").is_none()); } @@ -700,7 +736,13 @@ fn test_fetch_no_default_branch() { let initial_git_commit = empty_git_commit(&test_data.origin_repo, "refs/heads/main", &[]); let mut tx = test_data.repo.start_transaction("test"); - git::fetch(tx.mut_repo(), &test_data.git_repo, "origin", None).unwrap(); + git::fetch( + tx.mut_repo(), + &test_data.git_repo, + "origin", + git::RemoteCallbacks::default(), + ) + .unwrap(); empty_git_commit( &test_data.origin_repo, @@ -715,7 +757,13 @@ fn test_fetch_no_default_branch() { .set_head_detached(initial_git_commit.id()) .unwrap(); - let default_branch = git::fetch(tx.mut_repo(), &test_data.git_repo, "origin", None).unwrap(); + let default_branch = git::fetch( + tx.mut_repo(), + &test_data.git_repo, + "origin", + git::RemoteCallbacks::default(), + ) + .unwrap(); // There is no default branch assert_eq!(default_branch, None); } @@ -725,7 +773,12 @@ fn test_fetch_no_such_remote() { let test_data = GitRepoData::create(); let mut tx = test_data.repo.start_transaction("test"); - let result = git::fetch(tx.mut_repo(), &test_data.git_repo, "invalid-remote", None); + let result = git::fetch( + tx.mut_repo(), + &test_data.git_repo, + "invalid-remote", + git::RemoteCallbacks::default(), + ); assert!(matches!(result, Err(GitFetchError::NoSuchRemote(_)))); } @@ -774,6 +827,7 @@ fn test_push_updates_success() { force: false, new_target: Some(setup.new_commit.id().clone()), }], + git::RemoteCallbacks::default(), ); assert_eq!(result, Ok(())); @@ -815,6 +869,7 @@ fn test_push_updates_deletion() { force: false, new_target: None, }], + git::RemoteCallbacks::default(), ); assert_eq!(result, Ok(())); @@ -850,6 +905,7 @@ fn test_push_updates_mixed_deletion_and_addition() { new_target: Some(setup.new_commit.id().clone()), }, ], + git::RemoteCallbacks::default(), ); assert_eq!(result, Ok(())); @@ -883,6 +939,7 @@ fn test_push_updates_not_fast_forward() { force: false, new_target: Some(new_commit.id().clone()), }], + git::RemoteCallbacks::default(), ); assert_eq!(result, Err(GitPushError::NotFastForward)); } @@ -904,6 +961,7 @@ fn test_push_updates_not_fast_forward_with_force() { force: true, new_target: Some(new_commit.id().clone()), }], + git::RemoteCallbacks::default(), ); assert_eq!(result, Ok(())); @@ -930,6 +988,7 @@ fn test_push_updates_no_such_remote() { force: false, new_target: Some(setup.new_commit.id().clone()), }], + git::RemoteCallbacks::default(), ); assert!(matches!(result, Err(GitPushError::NoSuchRemote(_)))); } @@ -947,6 +1006,7 @@ fn test_push_updates_invalid_remote() { force: false, new_target: Some(setup.new_commit.id().clone()), }], + git::RemoteCallbacks::default(), ); assert!(matches!(result, Err(GitPushError::NoSuchRemote(_)))); } diff --git a/src/commands.rs b/src/commands.rs index 71f5a02968..1a9da4a7ae 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -18,7 +18,8 @@ use std::fs::OpenOptions; use std::io::{Read, Seek, SeekFrom, Write}; use std::ops::Range; use std::path::{Path, PathBuf}; -use std::sync::Arc; +use std::process::{Command, Stdio}; +use std::sync::{Arc, Mutex}; use std::time::Instant; use std::{fs, io}; @@ -37,7 +38,7 @@ use jujutsu_lib::matchers::{EverythingMatcher, Matcher}; use jujutsu_lib::op_store::{BranchTarget, RefTarget, WorkspaceId}; use jujutsu_lib::operation::Operation; use jujutsu_lib::refs::{classify_branch_push_action, BranchPushAction, BranchPushUpdate}; -use jujutsu_lib::repo::{MutableRepo, ReadonlyRepo, RepoRef}; +use jujutsu_lib::repo::{ReadonlyRepo, RepoRef}; use jujutsu_lib::repo_path::RepoPath; use jujutsu_lib::revset::RevsetExpression; use jujutsu_lib::revset_graph_iterator::{RevsetGraphEdge, RevsetGraphEdgeType}; @@ -3933,8 +3934,10 @@ fn cmd_git_fetch( let git_repo = get_git_repo(repo.store())?; let mut tx = workspace_command.start_transaction(&format!("fetch from git remote {}", &args.remote)); - git_fetch(ui, tx.mut_repo(), &git_repo, &args.remote) - .map_err(|err| UserError(err.to_string()))?; + with_remote_callbacks(ui, |cb| { + git::fetch(tx.mut_repo(), &git_repo, &args.remote, cb) + }) + .map_err(|err| UserError(err.to_string()))?; workspace_command.finish_transaction(ui, tx)?; Ok(()) } @@ -4041,40 +4044,125 @@ fn do_git_clone( let remote_name = "origin"; git_repo.remote(remote_name, source).unwrap(); let mut fetch_tx = workspace_command.start_transaction("fetch from git remote into empty repo"); - let maybe_default_branch = - git_fetch(ui, fetch_tx.mut_repo(), &git_repo, remote_name).map_err(|err| match err { - GitFetchError::NoSuchRemote(_) => { - panic!("shouldn't happen as we just created the git remote") - } - GitFetchError::InternalGitError(err) => UserError(format!("Fetch failed: {err}")), - })?; + + let maybe_default_branch = with_remote_callbacks(ui, |cb| { + git::fetch(fetch_tx.mut_repo(), &git_repo, remote_name, cb) + }) + .map_err(|err| match err { + GitFetchError::NoSuchRemote(_) => { + panic!("shouldn't happen as we just created the git remote") + } + GitFetchError::InternalGitError(err) => UserError(format!("Fetch failed: {err}")), + })?; workspace_command.finish_transaction(ui, fetch_tx)?; Ok((workspace_command, maybe_default_branch)) } -// Wrapper around git::fetch that adds progress feedback on TTYs -fn git_fetch( - ui: &mut Ui, - mut_repo: &mut MutableRepo, - git_repo: &git2::Repository, - remote_name: &str, -) -> Result, GitFetchError> { +#[allow(clippy::explicit_auto_deref)] // https://github.com/rust-lang/rust-clippy/issues/9763 +fn with_remote_callbacks(ui: &mut Ui, f: impl FnOnce(git::RemoteCallbacks<'_>) -> T) -> T { + let mut ui = Mutex::new(ui); let mut callback = None; - if ui.use_progress_indicator() { - let mut progress = Progress::new(Instant::now(), ui); + if ui.get_mut().unwrap().use_progress_indicator() { + let mut progress = Progress::new(Instant::now()); + let ui = &ui; callback = Some(move |x: &git::Progress| { - progress.update(Instant::now(), x); + _ = progress.update(Instant::now(), x, *ui.lock().unwrap()); }); } - let result = git::fetch( - mut_repo, - git_repo, - remote_name, - callback - .as_mut() - .map(|x| x as &mut dyn FnMut(&git::Progress)), - ); - result + let mut callbacks = git::RemoteCallbacks::default(); + callbacks.progress = callback + .as_mut() + .map(|x| x as &mut dyn FnMut(&git::Progress)); + let mut get_ssh_key = get_ssh_key; // Coerce to unit fn type + callbacks.get_ssh_key = Some(&mut get_ssh_key); + let mut get_pw = |url: &str, _username: &str| { + pinentry_get_pw(url).or_else(|| terminal_get_pw(*ui.lock().unwrap(), url)) + }; + callbacks.get_password = Some(&mut get_pw); + let mut get_user_pw = |url: &str| { + let ui = &mut *ui.lock().unwrap(); + Some((terminal_get_username(ui, url)?, terminal_get_pw(ui, url)?)) + }; + callbacks.get_username_password = Some(&mut get_user_pw); + f(callbacks) +} + +fn terminal_get_username(ui: &mut Ui, url: &str) -> Option { + ui.prompt(&format!("Username for {}", url)).ok() +} + +fn terminal_get_pw(ui: &mut Ui, url: &str) -> Option { + ui.prompt_password(&format!("Passphrase for {}: ", url)) + .ok() +} + +fn pinentry_get_pw(url: &str) -> Option { + let mut pinentry = Command::new("pinentry") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .ok()?; + #[rustfmt::skip] + pinentry + .stdin + .take() + .unwrap() + .write_all( + format!( + "SETTITLE jj passphrase\n\ + SETDESC Enter passphrase for {url}\n\ + SETPROMPT Passphrase:\n\ + GETPIN\n" + ) + .as_bytes(), + ) + .ok()?; + let mut out = String::new(); + pinentry + .stdout + .take() + .unwrap() + .read_to_string(&mut out) + .ok()?; + _ = pinentry.wait(); + for line in out.split('\n') { + if !line.starts_with("D ") { + continue; + } + let (_, encoded) = line.split_at(2); + return decode_assuan_data(encoded); + } + None +} + +// https://www.gnupg.org/documentation/manuals/assuan/Server-responses.html#Server-responses +fn decode_assuan_data(encoded: &str) -> Option { + let encoded = encoded.as_bytes(); + let mut decoded = Vec::with_capacity(encoded.len()); + let mut i = 0; + while i < encoded.len() { + if encoded[i] != b'%' { + decoded.push(encoded[i]); + i += 1; + continue; + } + i += 1; + let byte = + u8::from_str_radix(std::str::from_utf8(encoded.get(i..i + 2)?).ok()?, 16).ok()?; + decoded.push(byte); + i += 2; + } + String::from_utf8(decoded).ok() +} + +fn get_ssh_key(_username: &str) -> Option { + let home_dir = std::env::var("HOME").ok()?; + let key_path = std::path::Path::new(&home_dir).join(".ssh").join("id_rsa"); + if key_path.is_file() { + Some(key_path) + } else { + None + } } fn cmd_git_push( @@ -4334,8 +4422,10 @@ fn cmd_git_push( } let git_repo = get_git_repo(repo.store())?; - git::push_updates(&git_repo, &args.remote, &ref_updates) - .map_err(|err| UserError(err.to_string()))?; + with_remote_callbacks(ui, |cb| { + git::push_updates(&git_repo, &args.remote, &ref_updates, cb) + }) + .map_err(|err| UserError(err.to_string()))?; git::import_refs(tx.mut_repo(), &git_repo)?; workspace_command.finish_transaction(ui, tx)?; Ok(()) diff --git a/src/progress.rs b/src/progress.rs index c1609f3373..6bccd0ca23 100644 --- a/src/progress.rs +++ b/src/progress.rs @@ -1,3 +1,4 @@ +use std::io; use std::time::{Duration, Instant}; use crossterm::terminal::{Clear, ClearType}; @@ -5,32 +6,39 @@ use jujutsu_lib::git; use crate::ui::Ui; -pub struct Progress<'a> { - ui: &'a mut Ui, +pub struct Progress { next_print: Instant, rate: RateEstimate, buffer: String, } -impl<'a> Progress<'a> { - pub fn new(now: Instant, ui: &'a mut Ui) -> Self { +impl Progress { + pub fn new(now: Instant) -> Self { Self { - ui, next_print: now + INITIAL_DELAY, rate: RateEstimate::new(), buffer: String::new(), } } - pub fn update(&mut self, now: Instant, progress: &git::Progress) { + pub fn update( + &mut self, + now: Instant, + progress: &git::Progress, + ui: &mut Ui, + ) -> io::Result<()> { use std::fmt::Write as _; + if progress.overall == 1.0 { + write!(ui, "\r{}", Clear(ClearType::CurrentLine))?; + return Ok(()); + } + let rate = progress .bytes_downloaded .and_then(|x| self.rate.update(now, x)); - if now < self.next_print { - return; + return Ok(()); } self.next_print = now.min(self.next_print + Duration::from_secs(1) / UPDATE_HZ); @@ -43,8 +51,7 @@ impl<'a> Progress<'a> { write!(self.buffer, " at {: >5.1} {}B/s ", scaled, prefix).unwrap(); } - let bar_width = self - .ui + let bar_width = ui .size() .map(|(cols, _rows)| usize::from(cols)) .unwrap_or(0) @@ -53,14 +60,9 @@ impl<'a> Progress<'a> { draw_progress(progress.overall, &mut self.buffer, bar_width); self.buffer.push(']'); - _ = write!(self.ui, "{}", self.buffer); - _ = self.ui.flush(); - } -} - -impl Drop for Progress<'_> { - fn drop(&mut self) { - _ = write!(self.ui, "\r{}", Clear(ClearType::CurrentLine)); + write!(ui, "{}", self.buffer)?; + ui.flush()?; + Ok(()) } } diff --git a/src/ui.rs b/src/ui.rs index 7319eb0160..0562a35629 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -207,6 +207,30 @@ impl Ui { } } + pub fn prompt(&mut self, prompt: &str) -> io::Result { + if !atty::is(Stream::Stdout) { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "Cannot prompt for input since the output is not connected to a terminal", + )); + } + write!(self, "{}: ", prompt)?; + self.flush()?; + let mut buf = String::new(); + io::stdin().read_line(&mut buf)?; + Ok(buf) + } + + pub fn prompt_password(&mut self, prompt: &str) -> io::Result { + if !atty::is(Stream::Stdout) { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "Cannot prompt for input since the output is not connected to a terminal", + )); + } + rpassword::prompt_password(&format!("{}: ", prompt)) + } + pub fn size(&self) -> Option<(u16, u16)> { crossterm::terminal::size().ok() }