Skip to content

Commit

Permalink
Fix "read interrupted" error from passing through Ctrl+C
Browse files Browse the repository at this point in the history
Signed-off-by: itowlson <[email protected]>
  • Loading branch information
itowlson committed Jun 14, 2024
1 parent 4bba66e commit 23e97a6
Show file tree
Hide file tree
Showing 8 changed files with 106 additions and 22 deletions.
46 changes: 46 additions & 0 deletions crates/common/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,49 @@ use std::path::Path;
pub fn quoted_path(path: impl AsRef<Path>) -> impl std::fmt::Display {
format!("\"{}\"", path.as_ref().display())
}

/// An operation that may be interrupted using Ctrl+C. This is usable only
/// when Ctrl+C is handled via the ctrlc crate - otherwise Ctrl+C terminates
/// the program. But in such situations, this trait helps you to convert
/// the interrupt into a 'cancel' value which you can use to gracefully
/// exit just as if the interrupt had been allowed to go through.
pub trait Interruptible {
/// The result type that captures the cancellation.
type Result;

/// Converts an interrupt error to a value representing cancellation.
fn cancel_on_interrupt(self) -> Self::Result;
}

impl<T> Interruptible for Result<Option<T>, std::io::Error> {
type Result = Self;
fn cancel_on_interrupt(self) -> Self::Result {
match self {
Ok(opt) => Ok(opt),
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => Ok(None),
Err(e) => Err(e),
}
}
}

impl Interruptible for Result<bool, std::io::Error> {
type Result = Self;
fn cancel_on_interrupt(self) -> Self::Result {
match self {
Ok(b) => Ok(b),
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => Ok(false),
Err(e) => Err(e),
}
}
}

impl Interruptible for Result<String, std::io::Error> {
type Result = Result<Option<String>, std::io::Error>;
fn cancel_on_interrupt(self) -> Self::Result {
match self {
Ok(s) => Ok(Some(s)),
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => Ok(None),
Err(e) => Err(e),
}
}
}
20 changes: 13 additions & 7 deletions crates/templates/src/interaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use crate::{
use anyhow::anyhow;
// use console::style;
use dialoguer::{Confirm, Input};
use spin_common::ui::Interruptible;

pub(crate) trait InteractionStrategy {
fn allow_generate_into(&self, target_dir: &Path) -> Cancellable<(), anyhow::Error>;
Expand Down Expand Up @@ -45,7 +46,7 @@ impl InteractionStrategy for Interactive {
"Directory '{}' already contains other files. Generate into it anyway?",
target_dir.display()
);
match crate::interaction::confirm(&prompt) {
match crate::interaction::confirm(&prompt).cancel_on_interrupt() {
Ok(true) => Cancellable::Ok(()),
Ok(false) => Cancellable::Cancelled,
Err(e) => Cancellable::Err(anyhow::Error::from(e)),
Expand Down Expand Up @@ -115,28 +116,33 @@ pub(crate) fn prompt_parameter(parameter: &TemplateParameter) -> Option<String>
};

match input {
Ok(text) => match parameter.validate_value(text) {
Cancellable::Ok(text) => match parameter.validate_value(text) {
Ok(text) => return Some(text),
Err(e) => {
println!("Invalid value: {}", e);
}
},
Err(e) => {
println!("Invalid value: {}", e);
Cancellable::Cancelled => {
return None;
}
Cancellable::Err(e) => {
println!("Invalid value: {}", e);
}
}
}
}

fn ask_free_text(prompt: &str, default_value: &Option<String>) -> anyhow::Result<String> {
fn ask_free_text(
prompt: &str,
default_value: &Option<String>,
) -> Cancellable<String, std::io::Error> {
let mut input = Input::<String>::new();
input.with_prompt(prompt);
if let Some(s) = default_value {
input.default(s.to_owned());
}
let result = input.interact_text()?;
Ok(result)
let result = input.interact_text().cancel_on_interrupt();
result.into()
}

fn is_directory_empty(path: &Path) -> bool {
Expand Down
7 changes: 5 additions & 2 deletions src/commands/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use std::{fmt::Debug, path::PathBuf};
use anyhow::Result;
use clap::Parser;
use dialoguer::{console::Emoji, Confirm, Select};
use spin_common::ui::Interruptible;
use spin_doctor::{Diagnosis, DryRunNotSupported, PatientDiagnosis};

use crate::opts::{APP_MANIFEST_FILE_OPT, DEFAULT_MANIFEST_FILE};
Expand Down Expand Up @@ -110,7 +111,8 @@ fn prompt_treatment(summary: String, dry_run: Option<String>) -> Result<bool> {
.with_prompt(prompt)
.items(&items)
.default(0)
.interact_opt()?;
.interact_opt()
.cancel_on_interrupt()?;

match selection {
Some(2) => {
Expand All @@ -122,7 +124,8 @@ fn prompt_treatment(summary: String, dry_run: Option<String>) -> Result<bool> {
Ok(Confirm::new()
.with_prompt("Would you like to apply this fix?")
.default(true)
.interact_opt()?
.interact_opt()
.cancel_on_interrupt()?
.unwrap_or_default())
}
Some(0) => Ok(true),
Expand Down
5 changes: 3 additions & 2 deletions src/commands/external.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use crate::build_info::*;
use crate::commands::plugins::{update, Install};
use crate::opts::PLUGIN_OVERRIDE_COMPATIBILITY_CHECK_FLAG;
use anyhow::{anyhow, Result};
use spin_common::ui::quoted_path;
use spin_common::ui::{quoted_path, Interruptible};
use spin_plugins::{
badger::BadgerChecker, error::Error as PluginError, manifest::warn_unsupported_version,
PluginStore,
Expand Down Expand Up @@ -201,7 +201,8 @@ fn offer_install(
let choice = dialoguer::Confirm::new()
.with_prompt("Would you like to install this plugin and run it now?")
.default(false)
.interact_opt()?
.interact_opt()
.cancel_on_interrupt()?
.unwrap_or(false);
Ok(choice)
}
Expand Down
20 changes: 15 additions & 5 deletions src/commands/new.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use itertools::Itertools;
use path_absolutize::Absolutize;
use tokio;

use spin_common::ui::Interruptible;
use spin_templates::{RunOptions, Template, TemplateManager, TemplateVariantInfo};

use crate::opts::{APP_MANIFEST_FILE_OPT, DEFAULT_MANIFEST_FILE};
Expand Down Expand Up @@ -165,7 +166,10 @@ impl TemplateNewCommandCore {

let name = match &name {
Some(name) => name.to_owned(),
None => prompt_name(&variant).await?,
None => match prompt_name(&variant).await? {
Some(name) => name,
None => return Ok(()),
},
};

let output_path = if self.init {
Expand Down Expand Up @@ -315,7 +319,8 @@ async fn prompt_template(
.with_prompt(prompt)
.items(&opts)
.default(0)
.interact_opt()?
.interact_opt()
.cancel_on_interrupt()?
{
Some(i) => i,
None => return Ok(None),
Expand All @@ -336,18 +341,23 @@ async fn list_or_install_templates(
}
}

async fn prompt_name(variant: &TemplateVariantInfo) -> anyhow::Result<String> {
async fn prompt_name(variant: &TemplateVariantInfo) -> anyhow::Result<Option<String>> {
let noun = variant.prompt_noun();
let mut prompt = format!("Enter a name for your new {noun}");
loop {
let result = dialoguer::Input::<String>::new()
.with_prompt(prompt)
.interact_text()?;
.interact_text()
.cancel_on_interrupt()?;
let Some(result) = result else {
return Ok(None);
};

if result.trim().is_empty() {
prompt = format!("Name is required. Try another {noun} name (or Crl+C to exit)");
continue;
} else {
return Ok(result);
return Ok(Some(result));
}
}
}
Expand Down
10 changes: 8 additions & 2 deletions src/commands/plugins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
use anyhow::{anyhow, Context, Result};
use clap::{Parser, Subcommand};
use semver::Version;
use spin_common::ui::Interruptible;
use spin_plugins::{
error::Error,
lookup::{fetch_plugins_repo, plugins_repo_url, PluginLookup},
Expand Down Expand Up @@ -327,7 +328,11 @@ impl Upgrade {
eprintln!(
"Select plugins to upgrade. Use Space to select/deselect and Enter to confirm selection."
);
let selected_indexes = match dialoguer::MultiSelect::new().items(&names).interact_opt()? {
let selected_indexes = match dialoguer::MultiSelect::new()
.items(&names)
.interact_opt()
.cancel_on_interrupt()?
{
Some(indexes) => indexes,
None => return Ok(()),
};
Expand Down Expand Up @@ -663,7 +668,8 @@ fn prompt_confirm_install(manifest: &PluginManifest, package: &PluginPackage) ->
let install = dialoguer::Confirm::new()
.with_prompt(prompt)
.default(true)
.interact_opt()?
.interact_opt()
.cancel_on_interrupt()?
.unwrap_or(false);
if !install {
println!("Plugin '{}' will not be installed", manifest.name());
Expand Down
13 changes: 11 additions & 2 deletions src/commands/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use crate::opts::*;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use indicatif::{ProgressBar, ProgressStyle};
use spin_common::arg_parser::parse_kv;
use spin_common::{arg_parser::parse_kv, ui::Interruptible};
use spin_oci::Client;
use std::{io::Read, path::PathBuf, time::Duration};

Expand Down Expand Up @@ -155,14 +155,23 @@ pub struct Login {

impl Login {
pub async fn run(self) -> Result<()> {
// This may use dialoguer so we work around https://github.com/console-rs/dialoguer/issues/294
_ = ctrlc::set_handler(|| {
_ = dialoguer::console::Term::stderr().show_cursor();
});

let username = match self.username {
Some(u) => u,
None => {
let prompt = "Username";
loop {
let result = dialoguer::Input::<String>::new()
.with_prompt(prompt)
.interact_text()?;
.interact_text()
.cancel_on_interrupt()?;
let Some(result) = result else {
return Ok(());
};
if result.trim().is_empty() {
continue;
} else {
Expand Down
7 changes: 5 additions & 2 deletions src/commands/templates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use comfy_table::Table;
use path_absolutize::Absolutize;

use serde::Serialize;
use spin_common::ui::Interruptible;
use spin_templates::{
InstallOptions, InstallationResults, InstalledTemplateWarning, ListResults, ProgressReporter,
SkippedReason, Template, TemplateManager, TemplateSource,
Expand Down Expand Up @@ -326,7 +327,8 @@ impl Upgrade {
eprintln!("Select repos to upgrade. Use Space to select/deselect and Enter to confirm selection.");
let selected_indexes = match dialoguer::MultiSelect::new()
.items(&sources)
.interact_opt()?
.interact_opt()
.cancel_on_interrupt()?
{
Some(indexes) => indexes,
None => return Ok(None),
Expand Down Expand Up @@ -617,7 +619,8 @@ pub(crate) async fn prompt_install_default_templates(
let should_install = dialoguer::Confirm::new()
.with_prompt(DEFAULT_TEMPLATES_INSTALL_PROMPT)
.default(true)
.interact_opt()?;
.interact_opt()
.cancel_on_interrupt()?;
if should_install == Some(true) {
install_default_templates().await?;
Ok(Some(template_manager.list().await?.templates))
Expand Down

0 comments on commit 23e97a6

Please sign in to comment.