diff --git a/Cargo.toml b/Cargo.toml index 44b51d6..2ee8094 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,6 @@ resolver = "2" members = [ "crates/catppuccin-egui", "crates/egui-dock", - "crates/egui-form", "crates/egui-term", "crates/egui-theme-switch", "crates/egui-toast", @@ -31,6 +30,8 @@ duplicate = "2" eframe = "0.31" egui = "0.31" egui_extras = "0.31" +egui_form = "0.5" +egui-phosphor = "0.9" egui-theme-switch = "0.2.3" egui-toast = "0.16" garde = "0.22" diff --git a/README.md b/README.md index b8b0382..3b201b6 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ An open-source, cross-platform SSH session manager powered by - - -egui_form adds form validation to egui. -It can either use [validator](https://crates.io/crates/validator) -or [garde](https://crates.io/crates/garde) for validation. -This also means, if you use rust you can use the same validation logic -on the server and the client. - -Check the docs for the [validator implementation](https://docs.rs/egui_form/latest/egui_form/validator/index.html) -or the [garde implementation](https://docs.rs/egui_form/latest/egui_form/garde/index.html) -to get started. - -You can also build a custom implementation by implementing the `EguiValidationReport` for the result of whatever -form validation crate you use. - -## Showcase - -You can [try the Signup Form example](https://lucasmerlin.github.io/hello_egui/#/example/signup_form) in hello_egui -showcase app. - -Also, here's a screenshot from HelloPaint's profile form: - -![screenshot](https://github.com/lucasmerlin/hello_egui/blob/main/crates/egui_form/screenshot.png?raw=true) - -## Should I use validator or garde? - -For small / prototype projects, I'd recommend garde, since it has built in error messages. -For bigger projects that might require i18n, it might make sense to use validator, -since it allows for custom error messages (garde as of now has no i18n support). - -In HelloPaint I'm using garde, since it seems a bit cleaner and more active, hoping -that i18n will be solved before it becomes a problem for HelloPaint. - -## Minimal example using garde - -From [egui_form_minimal.rs](https://github.com/lucasmerlin/hello_egui/blob/main/crates/egui_form/examples/egui_form_minimal.rs) - -```rust -use eframe::NativeOptions; -use egui::{TextEdit, Ui}; -use egui_form::garde::{GardeReport, field_path}; -use egui_form::{Form, FormField}; -use garde::Validate; - - -#[derive(Debug, Default, Validate)] -struct Fields { - #[garde(length(min = 2, max = 50))] - user_name: String, -} - -fn form_ui(ui: &mut Ui, fields: &mut Fields) { - let mut form = Form::new().add_report(GardeReport::new(fields.validate())); - - FormField::new(&mut form, field_path!("user_name")) - .label("User Name") - .ui(ui, TextEdit::singleline(&mut fields.user_name)); - - if let Some(Ok(())) = form.handle_submit(&ui.button("Submit"), ui) { - println!("Submitted: {:?}", fields); - } -} -``` diff --git a/crates/egui-form/examples/egui_form_minimal.rs b/crates/egui-form/examples/egui_form_minimal.rs deleted file mode 100644 index e3af6ab..0000000 --- a/crates/egui-form/examples/egui_form_minimal.rs +++ /dev/null @@ -1,37 +0,0 @@ -use eframe::NativeOptions; -use egui::{TextEdit, Ui}; -use egui_form::garde::{field_path, GardeReport}; -use egui_form::{Form, FormField}; -use garde::Validate; - -#[derive(Debug, Default, Validate)] -struct Fields { - #[garde(length(min = 2, max = 50))] - user_name: String, -} - -fn form_ui(ui: &mut Ui, fields: &mut Fields) { - let mut form = Form::new().add_report(GardeReport::new(fields.validate())); - - FormField::new(&mut form, field_path!("user_name")) - .label("User Name") - .ui(ui, TextEdit::singleline(&mut fields.user_name)); - - if let Some(Ok(())) = form.handle_submit(&ui.button("Submit"), ui) { - println!("Submitted: {fields:?}"); - } -} - -fn main() -> eframe::Result<()> { - let mut fields = Fields::default(); - - eframe::run_simple_native( - "egui-form minimal example", - NativeOptions::default(), - move |ctx, _frame| { - egui::CentralPanel::default().show(ctx, |ui| { - form_ui(ui, &mut fields); - }); - }, - ) -} diff --git a/crates/egui-form/examples/garde.rs b/crates/egui-form/examples/garde.rs deleted file mode 100644 index 4223ff6..0000000 --- a/crates/egui-form/examples/garde.rs +++ /dev/null @@ -1,99 +0,0 @@ -use eframe::NativeOptions; -use egui::CentralPanel; - -use egui_form::garde::field_path; -use egui_form::{Form, FormField}; -use garde::Validate; - -#[derive(Validate, Debug)] -struct Test { - #[garde(length(min = 3, max = 10))] - pub user_name: String, - #[garde(email)] - pub email: String, - #[garde(dive)] - pub nested: Nested, - #[garde(dive)] - pub vec: Vec, -} - -#[derive(Validate, Debug)] -struct Nested { - #[garde(range(min = 1, max = 10))] - pub test: u64, -} - -fn form_ui(ui: &mut egui::Ui, test: &mut Test) { - let mut form = Form::new().add_report(egui_form::garde::GardeReport::new(test.validate())); - - FormField::new(&mut form, "user_name") - .label("User Name") - .ui(ui, egui::TextEdit::singleline(&mut test.user_name)); - FormField::new(&mut form, "email") - .label("Email") - .ui(ui, egui::TextEdit::singleline(&mut test.email)); - FormField::new(&mut form, field_path!("nested", "test")) - .label("Nested Test") - .ui(ui, egui::Slider::new(&mut test.nested.test, 0..=11)); - FormField::new(&mut form, field_path!("vec", 0, "test")) - .label("Vec Test") - .ui( - ui, - egui::DragValue::new(&mut test.vec[0].test).range(0..=11), - ); - - if let Some(Ok(())) = form.handle_submit(&ui.button("Submit"), ui) { - println!("Form submitted: {test:?}"); - } -} - -fn main() -> eframe::Result<()> { - let mut test = Test { - user_name: "testfiwuehfwoi".to_string(), - email: "lefwojwfpke".to_string(), - nested: Nested { test: 0 }, - vec: vec![Nested { test: 0 }], - }; - - eframe::run_simple_native( - "Egui Garde Validation", - NativeOptions::default(), - move |ctx, _frame| { - CentralPanel::default().show(ctx, |ui| { - form_ui(ui, &mut test); - }); - }, - ) -} - -#[cfg(test)] -mod tests { - use super::*; - use egui_form::garde::GardeReport; - use egui_form::{EguiValidationReport, IntoFieldPath}; - - #[test] - fn test() { - let test = Test { - user_name: "testfiwuehfwoi".to_string(), - email: "garbage".to_string(), - nested: Nested { test: 0 }, - vec: vec![Nested { test: 0 }], - }; - - let report = GardeReport::new(test.validate()); - - assert!(report - .get_field_error("user_name".into_field_path()) - .is_some()); - assert!(report.get_field_error(field_path!("email")).is_some()); - assert!(report - .get_field_error(field_path!("nested", "test")) - .is_some()); - assert!(report - .get_field_error(field_path!("vec", 0, "test")) - .is_some()); - - assert_eq!(report.error_count(), 4); - } -} diff --git a/crates/egui-form/examples/validator.rs b/crates/egui-form/examples/validator.rs deleted file mode 100644 index 3338e61..0000000 --- a/crates/egui-form/examples/validator.rs +++ /dev/null @@ -1,122 +0,0 @@ -use eframe::NativeOptions; -use egui::CentralPanel; -use egui_form::validator::field_path; -use egui_form::Form; -use egui_form::FormField; -use validator::Validate; - -#[derive(Validate, Debug)] -struct Test { - #[validate(length(min = 3, max = 10))] - pub user_name: String, - #[validate(email)] - pub email: String, - #[validate(nested)] - pub nested: Nested, - #[validate(nested)] - pub vec: Vec, -} - -#[derive(Validate, Debug)] -struct Nested { - #[validate(range( - min = 1, - max = 10, - message = "Custom Message: Must be between 1 and 10" - ))] - pub test: u64, -} - -fn form_ui(ui: &mut egui::Ui, test: &mut Test) { - let mut form = Form::new().add_report( - egui_form::validator::ValidatorReport::new(test.validate()).with_translation(|error| { - // Since validator doesn't have default messages, we have to provide our own - if let Some(msg) = &error.message { - return msg.clone(); - } - - match error.code.as_ref() { - "email" => "Invalid email".into(), - "length" => format!( - "Must be between {} and {} characters long", - error.params["min"], error.params["max"] - ) - .into(), - _ => format!("Validation Failed: {}", error.code).into(), - } - }), - ); - - FormField::new(&mut form, "user_name") - .label("User Name") - .ui(ui, egui::TextEdit::singleline(&mut test.user_name)); - FormField::new(&mut form, "email") - .label("Email") - .ui(ui, egui::TextEdit::singleline(&mut test.email)); - FormField::new(&mut form, field_path!("nested", "test")) - .label("Nested Test") - .ui(ui, egui::Slider::new(&mut test.nested.test, 0..=11)); - FormField::new(&mut form, field_path!("vec", 0, "test")) - .label("Vec Test") - .ui( - ui, - egui::DragValue::new(&mut test.vec[0].test).range(0..=11), - ); - - if let Some(Ok(())) = form.handle_submit(&ui.button("Submit"), ui) { - println!("Form submitted: {test:?}"); - } -} - -fn main() -> eframe::Result<()> { - let mut test = Test { - user_name: "testfiwuehfwoi".to_string(), - email: "garbage".to_string(), - nested: Nested { test: 0 }, - vec: vec![Nested { test: 0 }], - }; - - eframe::run_simple_native( - "Egui Validator Validation", - NativeOptions::default(), - move |ctx, _frame| { - CentralPanel::default().show(ctx, |ui| { - form_ui(ui, &mut test); - }); - }, - ) -} - -#[cfg(test)] -mod tests { - use super::*; - use egui_form::validator::field_path; - use egui_form::{EguiValidationReport, IntoFieldPath}; - - #[test] - fn test_validate() { - let test = Test { - user_name: "testfiwuehfwoi".to_string(), - email: "garbage".to_string(), - nested: Nested { test: 0 }, - vec: vec![Nested { test: 0 }], - }; - - let report = egui_form::validator::ValidatorReport::validate(test); - - assert!(report - .get_field_error(field_path!("user_name").into_field_path()) - .is_some()); - assert!(report - .get_field_error(field_path!("email").into_field_path()) - .is_some()); - assert!(report - .get_field_error(field_path!("nested", "test").into_field_path()) - .is_some()); - assert!(report - .get_field_error(field_path!("vec", 0, "test").into_field_path()) - .is_some()); - - assert_eq!(report.error_count(), 4); - } -} diff --git a/crates/egui-form/src/form.rs b/crates/egui-form/src/form.rs deleted file mode 100644 index bfbc85e..0000000 --- a/crates/egui-form/src/form.rs +++ /dev/null @@ -1,88 +0,0 @@ -use crate::EguiValidationReport; -use egui::{Response, Ui}; - -pub(crate) struct FormFieldState { - pub(crate) state_id: egui::Id, - pub(crate) widget_id: egui::Id, - // TODO: I don't think this is needed anymore - pub(crate) errors: Vec, -} - -/// Form connects the state of the individual form fields with the validation results. -/// It's also responsible for handling the submission and focusing the first invalid field on error. -pub struct Form { - pub(crate) controls: Vec, - pub(crate) validation_results: Vec, -} - -impl Default for Form { - fn default() -> Self { - Self::new() - } -} - -impl Form { - /// Create a new form. - pub fn new() -> Self { - Self { - controls: Vec::new(), - validation_results: Vec::new(), - } - } - - /// Add a validation report to the form. - /// This will be either a [`crate::validator::ValidatorReport`] or a [`crate::garde::GardeReport`]. - /// You can add multiple reports to the form. - /// You can also pass a custom Report for your own validation logic, if you implement [`EguiValidationReport`] for it. - pub fn add_report(mut self, value: R) -> Self { - self.validation_results.push(value); - self - } - - /// Handle the submission of the form. - /// You usually pass this a button response. - /// If this function returns Some(Ok(_)), the form data can be submitted. - /// - /// You can also use [`EguiValidationReport::try_submit`] directly. - pub fn handle_submit( - &mut self, - response: &Response, - ui: &mut Ui, - ) -> Option>> { - if response.clicked() { - Some(self.try_submit(ui)) - } else { - None - } - } - - /// Try to submit the form. - /// Returns Ok(()) if the form is valid, otherwise returns the errors. - pub fn try_submit(&mut self, ui: &mut Ui) -> Result<(), Vec<&R::Errors>> { - let has_errors = self - .validation_results - .iter() - .any(super::validation_report::EguiValidationReport::has_errors); - if has_errors { - ui.memory_mut(|mem| { - for control in &self.controls { - mem.data.insert_temp(control.state_id, true); - } - if let Some(first) = self - .controls - .iter() - .find(|control| !control.errors.is_empty()) - { - mem.request_focus(first.widget_id); - } - }); - Err(self - .validation_results - .iter() - .filter_map(|e| e.get_errors()) - .collect()) - } else { - Ok(()) - } - } -} diff --git a/crates/egui-form/src/form_field.rs b/crates/egui-form/src/form_field.rs deleted file mode 100644 index 3f087e9..0000000 --- a/crates/egui-form/src/form_field.rs +++ /dev/null @@ -1,121 +0,0 @@ -use crate::form::FormFieldState; -use crate::validation_report::IntoFieldPath; -use crate::{EguiValidationReport, Form}; -use egui::{Response, RichText, TextStyle, Widget}; -use std::borrow::Cow; - -/// A form field that can be validated. -/// Will color the field red (using the color from [`egui::style::Visuals::error_fg_color`]) if there is an error. -/// Will show the error message below the field if the field is blurred and there is an error. -pub struct FormField<'a, 'f, Errors: EguiValidationReport> { - error: Option>, - label: Option>, - form: Option<&'f mut Form>, -} - -impl<'a, 'f, Errors: EguiValidationReport> FormField<'a, 'f, Errors> { - /// Create a new `FormField`. - /// Pass a [Form] and a reference to the field you want to validate. - /// If you use [`crate::garde`], just pass the field name / path as a string. - /// If you use [`crate::validator`], pass a field reference using the [`crate::field_path`] macro. - pub fn new<'c, I: IntoFieldPath>>( - form: &'f mut Form, - into_field_path: I, - ) -> Self { - let field_path = into_field_path.into_field_path(); - let error = form - .validation_results - .iter() - .find_map(|errors| errors.get_field_error(field_path.clone())); - - FormField { - error, - label: None, - form: Some(form), - } - } - - /// Optionally set a label for the field. - pub fn label(mut self, label: impl Into>) -> Self { - self.label = Some(label.into()); - self - } - - /// Render the field. - pub fn ui(self, ui: &mut egui::Ui, content: impl Widget) -> Response { - let error = self.error; - - ui.vertical(|ui| { - let id = ui.auto_id_with("form_field"); - let blurred = ui.memory_mut(|mem| *mem.data.get_temp_mut_or(id, false)); - - let error_color = ui.style().visuals.error_fg_color; - - let show_error = error.is_some() && blurred; - - if show_error { - let widgets = &mut ui.style_mut().visuals.widgets; - widgets.inactive.bg_stroke.color = error_color; - widgets.inactive.bg_stroke.width = 1.0; - widgets.active.bg_stroke.color = error_color; - widgets.active.bg_stroke.width = 1.0; - widgets.hovered.bg_stroke.color = error_color; - widgets.hovered.bg_stroke.width = 1.0; - widgets.open.bg_stroke.color = error_color; - widgets.open.bg_stroke.width = 1.0; - } - - if let Some(label) = self.label { - let mut rich_text = RichText::new(label); - if show_error { - rich_text = rich_text.color(error_color); - } - ui.label( - rich_text.size( - ui.style() - .text_styles - .get(&TextStyle::Body) - .map_or(16.0, |s| s.size) - * 0.9, - ), - ); - } - - let response = content.ui(ui); - - if response.lost_focus() { - ui.memory_mut(|mem| { - mem.data.insert_temp(id, true); - }); - } - - if let Some(form) = self.form { - if let Some(error) = &error { - form.controls.push(FormFieldState { - state_id: id, - widget_id: response.id, - errors: vec![error.to_string()], - }); - } else { - form.controls.push(FormFieldState { - state_id: id, - widget_id: response.id, - errors: vec![], - }); - }; - } - - ui.add_visible( - show_error, - egui::Label::new( - RichText::new(error.as_deref().unwrap_or("")) - .color(error_color) - .small(), - ), - ); - - response - }) - .inner - } -} diff --git a/crates/egui-form/src/garde.rs b/crates/egui-form/src/garde.rs deleted file mode 100644 index 974dc57..0000000 --- a/crates/egui-form/src/garde.rs +++ /dev/null @@ -1,122 +0,0 @@ -use crate::EguiValidationReport; -use std::borrow::Cow; -use std::collections::BTreeMap; - -pub use crate::_garde_field_path as field_path; -use crate::validation_report::IntoFieldPath; -pub use garde; -use garde::Path; - -/// Create a [`garde::Path`] to be submitted to [`crate::FormField::new`] -/// Example: -/// ```rust -/// use egui-form::garde::field_path; -/// use garde::Path; -/// assert_eq!( -/// field_path!("root", "vec", 0, "nested"), -/// Path::new("root").join("vec").join(0).join("nested") -/// ) -/// ``` -#[macro_export] -macro_rules! _garde_field_path { - ( - $($field:expr $(,)?)+ - ) => { - $crate::garde::garde::Path::empty() - $( - .join($field) - )+ - }; -} - -/// A wrapper around a [`garde::Report`] that implements [`EguiValidationReport`]. -pub struct GardeReport(BTreeMap); - -impl GardeReport { - /// Create a new [`GardeReport`] from a [`garde::Report`]. - /// You can call this function with the result of a call to [`garde::Validate::validate`]. - /// - /// # Example - /// ``` - /// use egui-form::garde::{field_path, GardeReport}; - /// use egui-form::{EguiValidationReport, IntoFieldPath}; - /// use garde::Validate; - /// #[derive(Validate)] - /// struct Test { - /// #[garde(length(min = 3, max = 10))] - /// pub user_name: String, - /// #[garde(inner(length(min = 3, max = 10)))] - /// pub tags: Vec, - /// } - /// - /// let test = Test { - /// user_name: "testfiwuehfwoi".to_string(), - /// tags: vec!["tag1".to_string(), "waaaaytooooloooong".to_string()], - /// }; - /// - /// let report = GardeReport::new(test.validate()); - /// - /// assert!(report - /// .get_field_error(field_path!("user_name").into_field_path()) - /// .is_some()); - /// assert!(report - /// .get_field_error(field_path!("tags", 1).into_field_path()) - /// .is_some()); - /// ``` - pub fn new(result: Result<(), garde::Report>) -> Self { - if let Err(errors) = result { - GardeReport(errors.iter().cloned().collect()) - } else { - GardeReport(BTreeMap::new()) - } - } -} - -impl EguiValidationReport for GardeReport { - type FieldPath<'a> = Path; - type Errors = BTreeMap; - - fn get_field_error(&self, field: Self::FieldPath<'_>) -> Option> { - self.0.get(&field).map(|e| e.to_string().into()) - } - - fn has_errors(&self) -> bool { - !self.0.is_empty() - } - - fn error_count(&self) -> usize { - self.0.len() - } - - fn get_errors(&self) -> Option<&Self::Errors> { - if self.has_errors() { - Some(&self.0) - } else { - None - } - } -} - -impl IntoFieldPath for Path { - fn into_field_path(self) -> Path { - self - } -} - -impl IntoFieldPath for &str { - fn into_field_path(self) -> Path { - Path::new(self) - } -} - -impl IntoFieldPath for String { - fn into_field_path(self) -> Path { - Path::new(self) - } -} - -impl IntoFieldPath for usize { - fn into_field_path(self) -> Path { - Path::new(self) - } -} diff --git a/crates/egui-form/src/lib.rs b/crates/egui-form/src/lib.rs deleted file mode 100644 index cddaf2e..0000000 --- a/crates/egui-form/src/lib.rs +++ /dev/null @@ -1,152 +0,0 @@ -#![doc = include_str!("../README.md")] -#![forbid(unsafe_code)] -#![warn(missing_docs)] - -mod form; - -/// To use [garde] with `egui-form`, you need to create a [`garde::GardeReport`] and pass it to the [Form] instance. -/// -/// Then, when you create a [`FormField`], you pass the field's name as a &str. -/// For nested fields and arrays, the syntax for the field name looks like this: -/// `nested.array[0].field` -/// -/// # Example -/// ```no_run -/// # use eframe::NativeOptions; -/// # use egui::CentralPanel; -/// # -/// # use egui-form::{Form, FormField}; -/// # use garde::Validate; -/// # use egui-form::garde::field_path; -/// -/// #[derive(Validate, Debug)] -/// struct Test { -/// #[garde(length(min = 3, max = 10))] -/// pub user_name: String, -/// #[garde(email)] -/// pub email: String, -/// #[garde(dive)] -/// pub nested: Nested, -/// #[garde(dive)] -/// pub vec: Vec, -/// } -/// -/// #[derive(Validate, Debug)] -/// struct Nested { -/// #[garde(range(min = 1, max = 10))] -/// pub test: u64, -/// } -/// -/// pub fn form_ui(ui: &mut egui::Ui, test: &mut Test) { -/// let mut form = Form::new().add_report(egui-form::garde::GardeReport::new(test.validate())); -/// -/// FormField::new(&mut form, field_path!("user_name")) -/// .label("User Name") -/// .ui(ui, egui::TextEdit::singleline(&mut test.user_name)); -/// FormField::new(&mut form, field_path!("email")) -/// .label("Email") -/// .ui(ui, egui::TextEdit::singleline(&mut test.email)); -/// FormField::new(&mut form, field_path!("nested", "test")) -/// .label("Nested Test") -/// .ui(ui, egui::Slider::new(&mut test.nested.test, 0..=11)); -/// FormField::new(&mut form, field_path!("vec", 0, "test")) -/// .label("Vec Test") -/// .ui( -/// ui, -/// egui::DragValue::new(&mut test.vec[0].test).clamp_range(0..=11), -/// ); -/// -/// if let Some(Ok(())) = form.handle_submit(&ui.button("Submit"), ui) { -/// println!("Form submitted: {:?}", test); -/// } -/// } -/// ``` -#[cfg(feature = "validator_garde")] -pub mod garde; -mod validation_report; - -mod form_field; -/// To use [validator] with `egui-form`, you need to create a [`validator::ValidatorReport`] and pass it to the [Form] instance. -/// -/// Then, when you create a [`FormField`], you pass a slice of [`validator::PathItem`]s. -/// Usually, you would use the [`field_path`!] macro to create the slice. -/// For nested fields and arrays, the syntax for the field name looks like this: -/// `field_path!("nested", "array", 0, "field")` -/// -/// # Example -/// ```no_run -/// # // Taken 1:1 from crates/egui-form/examples/validator.rs -/// # use eframe::NativeOptions; -/// # use egui::CentralPanel; -/// # use egui-form::{Form, FormField}; -/// # use validator::Validate; -/// # use egui-form::validator::field_path; -/// -/// #[derive(Validate, Debug)] -/// struct Test { -/// #[validate(length(min = 3, max = 10))] -/// pub user_name: String, -/// #[validate(email)] -/// pub email: String, -/// #[validate(nested)] -/// pub nested: Nested, -/// #[validate(nested)] -/// pub vec: Vec, -/// } -/// -/// #[derive(Validate, Debug)] -/// struct Nested { -/// #[validate(range( -/// min = 1, -/// max = 10, -/// message = "Custom Message: Must be between 1 and 10" -/// ))] -/// pub test: u64, -/// } -/// -/// fn form_ui(ui: &mut egui::Ui, test: &mut Test) { -/// let mut form = Form::new().add_report( -/// egui-form::validator::ValidatorReport::new(test.validate()).with_translation(|error| { -/// // Since validator doesn't have default messages, we have to provide our own -/// if let Some(msg) = &error.message { -/// return msg.clone(); -/// } -/// -/// match error.code.as_ref() { -/// "email" => "Invalid email".into(), -/// "length" => format!( -/// "Must be between {} and {} characters long", -/// error.params["min"], error.params["max"] -/// ) -/// .into(), -/// _ => format!("Validation Failed: {}", error.code).into(), -/// } -/// }), -/// ); -/// -/// FormField::new(&mut form, field_path!("user_name")) -/// .label("User Name") -/// .ui(ui, egui::TextEdit::singleline(&mut test.user_name)); -/// FormField::new(&mut form, field_path!("email")) -/// .label("Email") -/// .ui(ui, egui::TextEdit::singleline(&mut test.email)); -/// FormField::new(&mut form, field_path!("nested", "test")) -/// .label("Nested Test") -/// .ui(ui, egui::Slider::new(&mut test.nested.test, 0..=11)); -/// FormField::new(&mut form, field_path!("vec", 0, "test")) -/// .label("Vec Test") -/// .ui( -/// ui, -/// egui::DragValue::new(&mut test.vec[0].test).clamp_range(0..=11), -/// ); -/// -/// if let Some(Ok(())) = form.handle_submit(&ui.button("Submit"), ui) { -/// println!("Form submitted: {:?}", test); -/// } -/// } -#[cfg(feature = "validator_validator")] -pub mod validator; - -pub use form::Form; -pub use form_field::*; -pub use validation_report::{EguiValidationReport, IntoFieldPath}; diff --git a/crates/egui-form/src/validation_report.rs b/crates/egui-form/src/validation_report.rs deleted file mode 100644 index 14f2861..0000000 --- a/crates/egui-form/src/validation_report.rs +++ /dev/null @@ -1,27 +0,0 @@ -use std::borrow::Cow; - -/// A trait telling `egui-form` how to parse validation errors. -pub trait EguiValidationReport { - /// The type used to identify fields. - type FieldPath<'a>: Clone; - /// The type of the errors. - type Errors; - - /// Returns the error message for a field. - fn get_field_error(&self, field: Self::FieldPath<'_>) -> Option>; - - /// Returns true if there are any errors. - fn has_errors(&self) -> bool; - - /// Returns the number of errors. - fn error_count(&self) -> usize; - - /// Returns a reference to the errors. - fn get_errors(&self) -> Option<&Self::Errors>; -} - -/// Helper trait to allow constructing non-nested `FormFields` without using the `field_path`!() macro -pub trait IntoFieldPath { - /// Conver this type into a [T] - fn into_field_path(self) -> T; -} diff --git a/crates/egui-form/src/validator.rs b/crates/egui-form/src/validator.rs deleted file mode 100644 index dca9d63..0000000 --- a/crates/egui-form/src/validator.rs +++ /dev/null @@ -1,202 +0,0 @@ -use crate::EguiValidationReport; -use std::borrow::Cow; - -pub use crate::_validator_field_path as field_path; -use crate::validation_report::IntoFieldPath; - -use std::hash::Hash; -pub use validator; -use validator::{Validate, ValidationError, ValidationErrors, ValidationErrorsKind}; - -/// Represents either a field in a struct or a indexed field in a list. -/// Usually created with the [`crate::field_path`] macro. -#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub enum PathItem<'a> { - /// Field in a struct. - Field(Cow<'a, str>), - /// Indexed field in a list. - Indexed(usize), -} - -impl From for PathItem<'_> { - fn from(value: usize) -> Self { - PathItem::Indexed(value) - } -} - -impl From for PathItem<'_> { - fn from(value: String) -> Self { - PathItem::Field(Cow::Owned(value)) - } -} - -impl<'a> From<&'a str> for PathItem<'a> { - fn from(value: &'a str) -> Self { - PathItem::Field(Cow::Borrowed(value)) - } -} - -/// Create a field path to be submitted to a [`crate::FormField::new`]. -/// This macro takes a list of field names and indexes and returns a slice of [`PathItem`]s. -/// # Example -/// ``` -/// use egui-form::validator::{PathItem, field_path}; -/// assert_eq!(field_path!("nested", "array", 0, "field"), &[ -/// PathItem::Field("nested".into()), -/// PathItem::Field("array".into()), -/// PathItem::Indexed(0), -/// PathItem::Field("field".into()), -/// ]); -#[macro_export] -macro_rules! _validator_field_path { - ( - $($field:expr $(,)?)+ - ) => { - [ - $( - $crate::validator::PathItem::from($field), - )+ - ].as_slice() - }; -} - -type GetTranslationFn = Box Cow<'static, str>>; - -/// Contains the validation errors from [validator] -pub struct ValidatorReport { - get_t: Option, - errors: Option, -} - -impl ValidatorReport { - /// Create a new [`ValidatorReport`] from a [`validator::ValidationErrors`]. - /// You can call this function with the result of a call to [`validator::Validate::validate`]. - pub fn new(result: Result<(), ValidationErrors>) -> Self { - ValidatorReport { - errors: result.err(), - get_t: None, - } - } - - /// Convenience function to validate a value and create a [`ValidatorReport`] from it. - pub fn validate(value: T) -> Self { - let result = value.validate(); - Self::new(result) - } - - /// Add a custom translation function to the report. - /// Pass a function that takes a [`ValidationError`] and returns a translated error message. - pub fn with_translation Cow<'static, str> + 'static>( - mut self, - get_t: F, - ) -> Self { - self.get_t = Some(Box::new(get_t)); - self - } -} - -fn get_error_recursively<'a>( - errors: &'a ValidationErrors, - fields: &[PathItem], -) -> Option<&'a Vec> { - if let Some((field, rest)) = fields.split_first() { - let PathItem::Field(field) = field else { - return None; - }; - match errors.0.get(field.as_ref()) { - Some(ValidationErrorsKind::Struct(errors)) => get_error_recursively(errors, rest), - Some(ValidationErrorsKind::List(errors)) => { - if let Some((PathItem::Indexed(index), rest)) = rest.split_first() { - if let Some(errors) = errors.get(index) { - get_error_recursively(errors, rest) - } else { - None - } - } else { - None - } - } - Some(ValidationErrorsKind::Field(errors)) => { - if rest.is_empty() { - Some(errors) - } else { - None - } - } - None => None, - } - } else { - None - } -} - -/// Helper enum to allow passing non nested field paths as a &str, without using the field_path!() macro -#[doc(hidden)] -#[derive(Clone)] -pub enum ValidatorPathType<'a> { - Single(PathItem<'a>), - Borrowed(&'a [PathItem<'a>]), -} - -impl EguiValidationReport for ValidatorReport { - type FieldPath<'a> = ValidatorPathType<'a>; - type Errors = ValidationErrors; - - fn get_field_error(&self, into_path: Self::FieldPath<'_>) -> Option> { - let path = into_path.into_field_path(); - - let error = if let Some(errors) = &self.errors { - match path { - ValidatorPathType::Single(item) => get_error_recursively(errors, &[item]), - ValidatorPathType::Borrowed(path) => get_error_recursively(errors, path), - } - } else { - None - }; - - if let Some(message) = error - .and_then(|errors| errors.first()) - .and_then(|e| e.message.as_ref()) - { - return Some(message.clone()); - } - - error.and_then(|errors| errors.first()).map(|error| { - if let Some(get_t) = &self.get_t { - get_t(error) - } else { - error.message.clone().unwrap_or_else(|| error.code.clone()) - } - }) - } - - fn has_errors(&self) -> bool { - self.errors.is_some() - } - - fn error_count(&self) -> usize { - self.errors.as_ref().map_or(0, |errors| errors.0.len()) - } - - fn get_errors(&self) -> Option<&Self::Errors> { - self.errors.as_ref() - } -} - -impl<'a> IntoFieldPath> for ValidatorPathType<'a> { - fn into_field_path(self) -> ValidatorPathType<'a> { - self - } -} - -impl<'a> IntoFieldPath> for &'a [PathItem<'a>] { - fn into_field_path(self) -> ValidatorPathType<'a> { - ValidatorPathType::Borrowed(self) - } -} - -impl<'a> IntoFieldPath> for &'a str { - fn into_field_path(self) -> ValidatorPathType<'a> { - ValidatorPathType::Single(PathItem::Field(Cow::Borrowed(self))) - } -} diff --git a/crates/egui-term/src/lib.rs b/crates/egui-term/src/lib.rs index ef5bfc9..26e1956 100644 --- a/crates/egui-term/src/lib.rs +++ b/crates/egui-term/src/lib.rs @@ -13,6 +13,6 @@ pub use alacritty::{PtyEvent, TermType, Terminal, TerminalContext}; pub use alacritty_terminal::term::TermMode; pub use bindings::{Binding, BindingAction, InputKind, KeyboardBinding}; pub use font::{FontSettings, TerminalFont}; -pub use ssh::SshOptions; +pub use ssh::{Authentication, SshOptions}; pub use theme::{ColorPalette, TerminalTheme}; pub use view::{TerminalOptions, TerminalView}; diff --git a/crates/egui-term/src/ssh/mod.rs b/crates/egui-term/src/ssh/mod.rs index 3a94bed..6b4bfa3 100644 --- a/crates/egui-term/src/ssh/mod.rs +++ b/crates/egui-term/src/ssh/mod.rs @@ -4,6 +4,7 @@ use alacritty_terminal::event::{OnResize, WindowSize}; use alacritty_terminal::tty::{ChildEvent, EventedPty, EventedReadWrite}; use anyhow::Context; use polling::{Event, PollMode, Poller}; +use std::collections::HashMap; use std::net::{TcpListener, TcpStream}; use std::sync::Arc; use tracing::{error, trace}; @@ -172,17 +173,25 @@ impl OnResize for Pty { } impl Pty { - pub fn new(mut opts: SshOptions) -> Result { + pub fn new(opts: SshOptions) -> Result { let mut config = Config::new(); - config.add_default_config_files(); - let port = opts.port.unwrap_or(22); - let mut config = config.for_host(opts.host); - config.insert("port".to_string(), port.to_string()); + let (mut auth_data, config) = match opts.auth { + Authentication::Password(user, password) => { + let port = opts.port.unwrap_or(22); + let mut config = config.for_host(opts.host); - if let Some(user) = opts.user.take() { - config.insert("user".to_string(), user); - } + config.insert("port".to_string(), port.to_string()); + config.insert("user".to_string(), user); + (Some(password), config) + } + Authentication::Config => { + config.add_default_config_files(); + let config = config.for_host(opts.host); + + (None, config) + } + }; smol::block_on(async move { let (session, events) = Session::connect(config)?; @@ -197,11 +206,19 @@ impl Pty { verify.answer(true).await.context("send verify response")?; } SessionEvent::Authenticate(auth) => { - if auth.prompts.is_empty() { - auth.answer(vec![]).await?; - } else if let Some(password) = opts.password.take() { - auth.answer(vec![password]).await?; + for a in auth.prompts.iter() { + println!("prompt: {}", a.prompt); + } + + let mut answers = vec![]; + for prompt in auth.prompts.iter() { + if prompt.prompt.contains("Password") { + let answer = auth_data.take(); + answers.push(answer.unwrap_or_default()); + } } + + auth.answer(answers).await?; } SessionEvent::HostVerificationFailed(failed) => { error!("host verification failed: {failed}"); @@ -215,8 +232,13 @@ impl Pty { } } + // FIXME: set in settings + let mut env = HashMap::new(); + env.insert("LANG".to_string(), "zh_CN.utf8".to_string()); + env.insert("LC_COLLATE".to_string(), "C".to_string()); + let (pty, child) = session - .request_pty("xterm-256color", PtySize::default(), None, None) + .request_pty("xterm-256color", PtySize::default(), None, Some(env)) .await?; let signal = tcp_signal()?; @@ -231,8 +253,13 @@ pub struct SshOptions { pub name: String, pub host: String, pub port: Option, - pub user: Option, - pub password: Option, + pub auth: Authentication, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Authentication { + Password(String, String), + Config, } fn tcp_signal() -> std::io::Result { diff --git a/crates/egui-term/src/view.rs b/crates/egui-term/src/view.rs index 6e4abd6..e707e57 100644 --- a/crates/egui-term/src/view.rs +++ b/crates/egui-term/src/view.rs @@ -69,6 +69,10 @@ impl Widget for TerminalView<'_> { layout.ctx.set_cursor_icon(CursorIcon::Default); } + if self.options.active_tab_id.is_none() { + self.has_focus = false; + } + // context menu if let Some(pos) = state.cursor_position { self.context_menu(pos, &layout, ui); diff --git a/nxshell/Cargo.toml b/nxshell/Cargo.toml index dd15a41..24f29da 100644 --- a/nxshell/Cargo.toml +++ b/nxshell/Cargo.toml @@ -16,15 +16,16 @@ chrono.workspace = true copypasta.workspace = true egui.workspace = true eframe = { workspace = true, features = [ - "accesskit", # Make egui compatible with screen readers. NOTE: adds a lot of dependencies. + "accesskit", # Make egui compatible with screen readers. NOTE: adds a lot of dependencies. "default_fonts", # Embed the default egui fonts. - "wgpu", # Use the glow rendering backend. Alternative: "wgpu". - "persistence", # Enable restoring app state when restarting the app. + "wgpu", # Use the glow rendering backend. Alternative: "wgpu". + "persistence", # Enable restoring app state when restarting the app. ] } egui-term = { path = "../crates/egui-term" } egui_dock = { path = "../crates/egui-dock" } egui_extras = { workspace = true, features = ["all_loaders"] } -egui_form = { path = "../crates/egui-form", features = ["validator_garde"] } +egui_form = { workspace = true, features = ["validator_garde"] } +egui-phosphor.workspace = true egui-theme-switch = { path = "../crates/egui-theme-switch" } egui-toast = { path = "../crates/egui-toast" } garde = { workspace = true, features = ["full"] } diff --git a/nxshell/src/app.rs b/nxshell/src/app.rs index d54c854..5ee483e 100644 --- a/nxshell/src/app.rs +++ b/nxshell/src/app.rs @@ -1,15 +1,15 @@ -use crate::db::{DbConn, Session}; +use crate::db::DbConn; use crate::errors::{error_toast, NxError}; -use crate::ui::form::NxStateManager; +use crate::ui::form::{AuthType, NxStateManager}; use crate::ui::tab_view::Tab; use copypasta::ClipboardContext; use eframe::{egui, NativeOptions}; -use egui::{Align2, CollapsingHeader, FontData, FontId, Id}; +use egui::{Align2, CollapsingHeader, FontData, FontId, Id, TextEdit}; use egui_dock::{DockState, NodeIndex, SurfaceIndex, TabIndex}; -use egui_term::{FontSettings, PtyEvent, SshOptions, TermType, TerminalFont}; +use egui_phosphor::regular::{DRONE, NUMPAD}; +use egui_term::{FontSettings, PtyEvent, TerminalFont}; use egui_theme_switch::global_theme_switch; use egui_toast::Toasts; -use orion::aead::{open, SecretKey}; use std::cell::RefCell; use std::rc::Rc; use std::sync::mpsc::{Receiver, Sender}; @@ -23,6 +23,7 @@ pub struct NxShellOptions { pub active_tab_id: Option, pub term_font: TerminalFont, pub term_font_size: f32, + pub session_filter: String, } impl Default for NxShellOptions { @@ -38,6 +39,7 @@ impl Default for NxShellOptions { multi_exec: false, term_font: TerminalFont::new(font_setting), term_font_size, + session_filter: String::default(), } } } @@ -106,37 +108,20 @@ impl eframe::App for NxShell { .resizable(true) .width_range(200.0..=300.0) .show(ctx, |ui| { - ui.label("Sessions"); - ui.separator(); + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| { + ui.label("Sessions"); + }); - if let Some(sessions) = self.state_manager.sessions.take() { - for (group, sessions) in sessions.iter() { - CollapsingHeader::new(group) - .default_open(true) - .show(ui, |ui| { - for session in sessions { - let response = ui.button(&session.name); - if response.double_clicked() { - match self.db.find_session(&session.group, &session.name) { - Ok(Some(session)) => { - if let Err(err) = - self.add_shell_tab_with_secret(ctx, session) - { - toasts.add(error_toast(err.to_string())); - } - } - Ok(None) => {} - Err(err) => { - toasts.add(error_toast(err.to_string())); - } - } - } else if response.secondary_clicked() { - } - } - }); - } - self.state_manager.sessions = Some(sessions); - } + // TODO: add close menu + // ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| { + // ui.label("Sessions"); + // }); + }); + + self.search_sessions(ui); + ui.separator(); + self.list_sessions(ctx, ui, &mut toasts); }); egui::TopBottomPanel::bottom("main_bottom_panel").show(ctx, |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| { @@ -156,6 +141,56 @@ impl eframe::App for NxShell { } } +impl NxShell { + fn search_sessions(&mut self, ui: &mut egui::Ui) { + let text_edit = TextEdit::singleline(&mut self.opts.session_filter); + let response = ui.add(text_edit); + if response.clicked() { + // gain ui focus + self.opts.active_tab_id = None; + } else if response.changed() { + if let Ok(sessions) = self.db.find_sessions(&self.opts.session_filter) { + self.state_manager.sessions = Some(sessions); + } + } + } + + fn list_sessions(&mut self, ctx: &egui::Context, ui: &mut egui::Ui, toasts: &mut Toasts) { + if let Some(sessions) = self.state_manager.sessions.take() { + for (group, sessions) in sessions.iter() { + CollapsingHeader::new(group) + .default_open(true) + .show(ui, |ui| { + for session in sessions { + let icon = match AuthType::from(session.auth_type) { + AuthType::Password => NUMPAD, + AuthType::Config => DRONE, + }; + let response = ui.button(format!("{icon} {}", session.name)); + if response.double_clicked() { + match self.db.find_session(&session.group, &session.name) { + Ok(Some(session)) => { + if let Err(err) = + self.add_shell_tab_with_secret(ctx, session) + { + toasts.add(error_toast(err.to_string())); + } + } + Ok(None) => {} + Err(err) => { + toasts.add(error_toast(err.to_string())); + } + } + } else if response.secondary_clicked() { + } + } + }); + } + self.state_manager.sessions = Some(sessions); + } + } +} + impl NxShell { fn recv_event(&mut self) { if let Ok((tab_id, PtyEvent::Exit)) = self.command_receiver.try_recv() { @@ -171,29 +206,6 @@ impl NxShell { } } } - - fn add_shell_tab_with_secret( - &mut self, - ctx: &egui::Context, - session: Session, - ) -> Result<(), NxError> { - let key = SecretKey::from_slice(&session.secret_key)?; - let password = open(&key, &session.secret_data)?; - let password = String::from_utf8(password)?; - self.add_shell_tab( - ctx.clone(), - TermType::Ssh { - options: SshOptions { - group: session.group, - name: session.name, - host: session.host, - port: Some(session.port), - user: Some(session.username), - password: Some(password), - }, - }, - ) - } } fn set_font(ctx: &egui::Context) { @@ -208,5 +220,9 @@ fn set_font(ctx: &egui::Context) { .entry(egui::FontFamily::Monospace) .or_default() .push(name.to_owned()); + + // add egui icon + egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular); + ctx.set_fonts(fonts); } diff --git a/nxshell/src/db.rs b/nxshell/src/db.rs index 60c439e..9d65bca 100644 --- a/nxshell/src/db.rs +++ b/nxshell/src/db.rs @@ -10,6 +10,7 @@ pub struct Session { pub name: String, pub host: String, pub port: u16, + pub auth_type: u16, pub username: String, pub secret_data: Vec, pub secret_key: Vec, @@ -31,6 +32,7 @@ impl DbConn { name TEXT NOT NULL, host TEXT NOT NULL, port INTEGER CHECK (port BETWEEN 1 AND 65535), + auth_type INTEGER CHECK (auth_type BETWEEN 0 AND 9), username TEXT NOT NULL, secret_data BLOB NOT NULL, secret_key BLOB NOT NULL, @@ -46,7 +48,7 @@ impl DbConn { pub fn find_all_sessions(&self) -> Result>> { let mut stmt = self .db - .prepare("SELECT id, group_name, name, host, port, username FROM session")?; + .prepare("SELECT id, group_name, name, auth_type FROM session")?; let mut rows = stmt.query(())?; let mut sessions = vec![]; while let Some(row) = rows.next()? { @@ -54,9 +56,36 @@ impl DbConn { id: row.get(0)?, group: row.get(1)?, name: row.get(2)?, - host: row.get(3)?, - port: row.get(4)?, - username: row.get(5)?, + auth_type: row.get(3)?, + ..Default::default() + }); + } + let mut session_groups: IndexMap> = + IndexMap::with_capacity(sessions.len()); + for session in sessions { + session_groups + .entry(session.group.clone()) + .or_default() + .push(session); + } + Ok(session_groups) + } + + pub fn find_sessions(&self, key: &str) -> Result>> { + if key.is_empty() { + return self.find_all_sessions(); + } + let mut stmt = self + .db + .prepare("SELECT id, group_name, name, auth_type FROM session where group_name like ?1 or name like ?1")?; + let mut rows = stmt.query((format!("%{key}%"),))?; + let mut sessions = vec![]; + while let Some(row) = rows.next()? { + sessions.push(Session { + id: row.get(0)?, + group: row.get(1)?, + name: row.get(2)?, + auth_type: row.get(3)?, ..Default::default() }); } @@ -74,14 +103,15 @@ impl DbConn { pub fn insert_session(&self, session: Session) -> Result<(), NxError> { let time = Local::now().timestamp_millis() as u64; self.db.execute( - "INSERT INTO session(group_name, name, host, port, \ + "INSERT INTO session(group_name, name, host, port, auth_type, \ username, secret_data, secret_key, create_time) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", ( &session.group, &session.name, &session.host, session.port, + &session.auth_type, &session.username, &session.secret_data, &session.secret_key, @@ -93,7 +123,7 @@ impl DbConn { pub fn find_session(&self, group_name: &str, name: &str) -> Result> { let mut stmt = self.db.prepare( - "SELECT id, group_name, name, host, port, \ + "SELECT id, group_name, name, host, port, auth_type, \ username, secret_data, secret_key, create_time FROM session \ WHERE group_name = ?1 AND name = ?2", )?; @@ -105,10 +135,11 @@ impl DbConn { name: row.get(2)?, host: row.get(3)?, port: row.get(4)?, - username: row.get(5)?, - secret_data: row.get(6)?, - secret_key: row.get(7)?, - create_time: row.get(8)?, + auth_type: row.get(5)?, + username: row.get(6)?, + secret_data: row.get(7)?, + secret_key: row.get(8)?, + create_time: row.get(9)?, })); } Ok(None) diff --git a/nxshell/src/lib.rs b/nxshell/src/lib.rs index 9e0d9d7..71999a5 100644 --- a/nxshell/src/lib.rs +++ b/nxshell/src/lib.rs @@ -3,5 +3,4 @@ pub mod consts; mod db; mod errors; mod security; -mod state; mod ui; diff --git a/nxshell/src/state.rs b/nxshell/src/state.rs deleted file mode 100644 index 8b13789..0000000 --- a/nxshell/src/state.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/nxshell/src/ui/form/mod.rs b/nxshell/src/ui/form/mod.rs index dab1c03..6b39ad7 100644 --- a/nxshell/src/ui/form/mod.rs +++ b/nxshell/src/ui/form/mod.rs @@ -2,6 +2,7 @@ use crate::db::Session; use indexmap::IndexMap; mod session; +pub use session::AuthType; #[derive(Default)] pub struct NxStateManager { diff --git a/nxshell/src/ui/form/session.rs b/nxshell/src/ui/form/session.rs index d6fae38..c21e6d4 100644 --- a/nxshell/src/ui/form/session.rs +++ b/nxshell/src/ui/form/session.rs @@ -1,18 +1,17 @@ use crate::app::NxShell; use crate::db::Session; use crate::errors::{error_toast, NxError}; -use egui::emath::Numeric; use egui::{ - Align2, CentralPanel, Context, Grid, Id, Layout, Order, TextBuffer, TextEdit, TopBottomPanel, + Align2, CentralPanel, ComboBox, Context, Grid, Id, Layout, Order, TextEdit, TopBottomPanel, Window, }; use egui_form::garde::GardeReport; use egui_form::{Form, FormField}; -use egui_term::{SshOptions, TermType}; +use egui_term::{Authentication, SshOptions, TermType}; use egui_toast::Toasts; use garde::Validate; use orion::aead::{seal, SecretKey}; -use std::ops::RangeInclusive; +use std::fmt::Display; use tracing::error; #[derive(Debug, Clone, Validate)] @@ -21,14 +20,42 @@ pub struct SessionState { pub group: String, #[garde(length(min = 0, max = 256))] pub name: String, - #[garde(ip)] + #[garde(length(min = 1))] pub host: String, #[garde(range(min = 1, max = 65535))] pub port: u16, - #[garde(length(min = 1, max = 256))] + #[garde(skip)] + pub auth_type: AuthType, + #[garde(skip)] pub username: String, - #[garde(length(min = 1, max = 256))] - pub password: String, + #[garde(skip)] + pub auth_data: String, +} + +#[repr(u16)] +#[derive(Debug, Clone, Copy, Default, Hash, PartialEq)] +pub enum AuthType { + #[default] + Password = 0, + Config = 1, +} + +impl Display for AuthType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AuthType::Password => write!(f, "Password"), + AuthType::Config => write!(f, "SSH Config"), + } + } +} + +impl From for AuthType { + fn from(value: u16) -> Self { + match value { + 0 => AuthType::Password, + _ => AuthType::Config, + } + } } impl Default for SessionState { @@ -38,8 +65,9 @@ impl Default for SessionState { name: String::default(), host: String::default(), port: 22, + auth_type: AuthType::Password, username: String::default(), - password: String::default(), + auth_data: String::default(), } } } @@ -100,7 +128,7 @@ impl NxShell { ui.horizontal(|ui| { ui.add_space(20.); - ssh_form(ui, &mut form, &mut session_state); + self.ssh_form(ui, &mut form, &mut session_state); }); }); }); @@ -114,29 +142,49 @@ impl NxShell { } fn submit_session(&mut self, ctx: &Context, session: &mut SessionState) -> Result<(), NxError> { + let (auth, secret_key, secret_data) = match session.auth_type { + AuthType::Password => { + if session.username.trim().is_empty() || session.auth_data.trim().is_empty() { + return Err(NxError::Plain( + "`username` and `password` cannot be empty in `Password` mode".to_string(), + )); + } + + let secret_key = SecretKey::generate(32)?; + let secret_data = seal(&secret_key, session.auth_data.as_bytes())?; + let secret_key = secret_key.unprotected_as_bytes().to_vec(); + + ( + Authentication::Password( + session.username.to_string(), + session.auth_data.to_string(), + ), + secret_key, + secret_data, + ) + } + AuthType::Config => (Authentication::Config, vec![], vec![]), + }; let typ = TermType::Ssh { options: SshOptions { group: session.group.to_string(), name: session.name.to_string(), host: session.host.to_string(), port: Some(session.port), - user: Some(session.username.to_string()), - password: Some(session.password.to_string()), + auth, }, }; self.add_shell_tab(ctx.clone(), typ)?; - let secret_key = SecretKey::generate(32)?; // 32字节密钥 - let secret_data = seal(&secret_key, session.password.as_bytes())?; - self.db.insert_session(Session { group: session.group.to_string(), name: session.name.to_string(), host: session.host.to_string(), port: session.port, + auth_type: session.auth_type as u16, username: session.username.to_string(), secret_data, - secret_key: secret_key.unprotected_as_bytes().to_vec(), + secret_key, ..Default::default() })?; @@ -145,54 +193,108 @@ impl NxShell { } Ok(()) } -} -fn ssh_form(ui: &mut egui::Ui, form: &mut Form, session: &mut SessionState) { - Grid::new("ssh_form_grid") - .num_columns(2) - .spacing([10.0, 8.0]) - .show(ui, |ui| { - // group - form_text_edit(ui, form, "Group:", &mut session.group, false); - // name - form_text_edit(ui, form, "Name:", &mut session.name, false); - // host - form_text_edit(ui, form, "Host:", &mut session.host, false); - // port - form_drag_value(ui, form, "Port:", &mut session.port, 1..=65535); - // username - form_text_edit(ui, form, "Username:", &mut session.username, false); - // password - form_text_edit(ui, form, "Password:", &mut session.password, true); - }); -} + fn ssh_form( + &mut self, + ui: &mut egui::Ui, + form: &mut Form, + session: &mut SessionState, + ) { + Grid::new("ssh_form_grid") + .num_columns(2) + .spacing([10.0, 15.0]) + .show(ui, |ui| { + // group + ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| { + ui.label("Group:"); + }); + FormField::new(form, "group").ui(ui, TextEdit::singleline(&mut session.group)); + ui.end_row(); -fn form_text_edit( - ui: &mut egui::Ui, - form: &mut Form, - label: &str, - text: &mut dyn TextBuffer, - is_password: bool, -) { - ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| { - ui.label(label); - }); - FormField::new(form, label.trim_end_matches(':').to_lowercase()) - .ui(ui, TextEdit::singleline(text).password(is_password)); - ui.end_row(); -} + // name + ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| { + ui.label("Name:"); + }); + FormField::new(form, "name").ui(ui, TextEdit::singleline(&mut session.name)); + ui.end_row(); + + // host + let host_label = match session.auth_type { + AuthType::Password => "Host:", + AuthType::Config => "Host Alias:", + }; -fn form_drag_value( - ui: &mut egui::Ui, - form: &mut Form, - label: &str, - value: &mut Num, - range: RangeInclusive, -) { - ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| { - ui.label(label); - }); - FormField::new(form, label.trim_end_matches(':').to_lowercase()) - .ui(ui, egui::DragValue::new(value).speed(1.).range(range)); - ui.end_row(); + ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(host_label); + }); + + ui.vertical_centered(|ui| { + ui.horizontal_centered(|ui| { + let host_edit = TextEdit::singleline(&mut session.host); + match session.auth_type { + AuthType::Password => { + FormField::new(form, "host") + .ui(ui, host_edit.char_limit(15).desired_width(150.)); + } + AuthType::Config => { + FormField::new(form, "host").ui(ui, host_edit); + } + } + + if let AuthType::Password = session.auth_type { + FormField::new(form, "port").ui( + ui, + egui::DragValue::new(&mut session.port) + .speed(1.) + .range(1..=65535), + ); + } + }); + }); + + ui.end_row(); + + // auth type + ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| { + ui.label("Auth Type:"); + }); + ComboBox::from_id_salt(session.auth_type) + .selected_text(session.auth_type.to_string()) + .width(160.) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut session.auth_type, + AuthType::Password, + AuthType::Password.to_string(), + ); + ui.selectable_value( + &mut session.auth_type, + AuthType::Config, + AuthType::Config.to_string(), + ); + }); + ui.end_row(); + + // FIXME: Why is the line height smaller in this row? + if let AuthType::Password = session.auth_type { + // username + ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| { + ui.label("Username:"); + }); + FormField::new(form, "username") + .ui(ui, TextEdit::singleline(&mut session.username)); + ui.end_row(); + + // password + ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| { + ui.label("Password:"); + }); + FormField::new(form, "auth_data").ui( + ui, + TextEdit::singleline(&mut session.auth_data).password(true), + ); + ui.end_row(); + } + }); + } } diff --git a/nxshell/src/ui/menubar.rs b/nxshell/src/ui/menubar.rs index 26f9a45..c66376c 100644 --- a/nxshell/src/ui/menubar.rs +++ b/nxshell/src/ui/menubar.rs @@ -1,14 +1,18 @@ use crate::app::NxShell; use crate::consts::{REPOSITORY_URL, SHOW_DOCK_PANEL_ONCE}; +use crate::db::Session; use crate::errors::NxError; use crate::ui::tab_view::Tab; use egui::{Button, Checkbox, Modifiers}; use egui_dock::DockState; -use egui_term::TermType; +use egui_term::{Authentication, SshOptions, TermType}; +use orion::aead::{open as orion_open, SecretKey}; use std::env; use std::process::Command; use tracing::error; +use super::form::AuthType; + const BTN_WIDTH: f32 = 200.0; impl NxShell { @@ -26,8 +30,8 @@ impl NxShell { } fn session_menu(&mut self, ui: &mut egui::Ui) { - let new_session_shortcut = egui::KeyboardShortcut::new(Modifiers::CTRL, egui::Key::N); - if ui.input_mut(|i| i.consume_shortcut(&new_session_shortcut)) { + let new_term_shortcut = egui::KeyboardShortcut::new(Modifiers::CTRL, egui::Key::N); + if ui.input_mut(|i| i.consume_shortcut(&new_term_shortcut)) { let _ = self.add_shell_tab( ui.ctx().clone(), TermType::Regular { @@ -36,12 +40,23 @@ impl NxShell { ); } ui.menu_button("Session", |ui| { - let new_session_shortcut = ui.ctx().format_shortcut(&new_session_shortcut); - let new_session_btn = Button::new("New Session") - .min_size((BTN_WIDTH, 0.).into()) - .shortcut_text(new_session_shortcut); + let new_session_btn = Button::new("New Session").min_size((BTN_WIDTH, 0.).into()); if ui.add(new_session_btn).clicked() { *self.opts.show_add_session_modal.borrow_mut() = true; + ui.close_menu(); + } + let new_term_shortcut = ui.ctx().format_shortcut(&new_term_shortcut); + let new_term_btn = Button::new("New Terminal") + .min_size((BTN_WIDTH, 0.).into()) + .shortcut_text(new_term_shortcut); + if ui.add(new_term_btn).clicked() { + let _ = self.add_shell_tab( + ui.ctx().clone(), + TermType::Regular { + working_directory: None, + }, + ); + ui.close_menu(); } ui.separator(); if ui.button("Quit").clicked() { @@ -76,6 +91,46 @@ impl NxShell { } } } + + pub fn add_shell_tab_with_secret( + &mut self, + ctx: &egui::Context, + session: Session, + ) -> Result<(), NxError> { + let auth = match AuthType::from(session.auth_type) { + AuthType::Password => { + let key = SecretKey::from_slice(&session.secret_key)?; + let auth_data = orion_open(&key, &session.secret_data)?; + let auth_data = String::from_utf8(auth_data)?; + + Authentication::Password(session.username, auth_data) + } + AuthType::Config => Authentication::Config, + }; + + self.add_shell_tab( + ctx.clone(), + TermType::Ssh { + options: SshOptions { + group: session.group, + name: session.name, + host: session.host, + port: Some(session.port), + auth, + }, + }, + ) + } + + pub fn add_sessions_tab(&mut self) { + if self.dock_state.surfaces_count() == 0 { + self.dock_state = DockState::new(vec![]); + } + SHOW_DOCK_PANEL_ONCE.call_once(|| { + self.opts.show_dock_panel = true; + }); + self.dock_state.push_to_focused_leaf(Tab::session_list()); + } } fn window_menu(ui: &mut egui::Ui) { diff --git a/nxshell/src/ui/mod.rs b/nxshell/src/ui/mod.rs index 1fbe785..3fff304 100644 --- a/nxshell/src/ui/mod.rs +++ b/nxshell/src/ui/mod.rs @@ -1,4 +1,3 @@ pub mod form; pub mod menubar; -pub mod modal; pub mod tab_view; diff --git a/nxshell/src/ui/modal/mod.rs b/nxshell/src/ui/modal/mod.rs deleted file mode 100644 index 8b13789..0000000 --- a/nxshell/src/ui/modal/mod.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/nxshell/src/ui/tab_view/mod.rs b/nxshell/src/ui/tab_view/mod.rs index 1c6643d..6b783df 100644 --- a/nxshell/src/ui/tab_view/mod.rs +++ b/nxshell/src/ui/tab_view/mod.rs @@ -7,8 +7,10 @@ use crate::ui::tab_view::session::SessionList; use copypasta::ClipboardContext; use egui::{Label, Response, Sense, Ui}; use egui_dock::{DockArea, Style}; +use egui_phosphor::regular::{DRONE, NUMPAD}; use egui_term::{ - PtyEvent, TermType, Terminal, TerminalContext, TerminalOptions, TerminalTheme, TerminalView, + Authentication, PtyEvent, TermType, Terminal, TerminalContext, TerminalOptions, TerminalTheme, + TerminalView, }; use homedir::my_home; use std::error::Error; @@ -79,7 +81,13 @@ impl egui_dock::TabViewer for TabViewer<'_> { fn title(&mut self, tab: &mut Self::Tab) -> egui::WidgetText { match &mut tab.inner { TabInner::Term(term) => match term.term_type { - TermType::Ssh { ref options } => options.name.to_owned().into(), + TermType::Ssh { ref options } => { + let icon = match options.auth { + Authentication::Config => DRONE, + Authentication::Password(..) => NUMPAD, + }; + format!("{icon} {}", options.name).into() + } TermType::Regular { .. } => "local".into(), }, TabInner::SessionList(_) => "sessions".into(), @@ -123,11 +131,13 @@ impl egui_dock::TabViewer for TabViewer<'_> { if response.hovered() { if let TabInner::Term(term) = &mut tab.inner { if let TermType::Ssh { options } = &term.term_type { - response.show_tooltip_text(format!( - "{}:{}", - options.host, - options.port.unwrap_or(22) - )); + if let Authentication::Password(..) = options.auth { + response.show_tooltip_text(format!( + "{}:{}", + options.host, + options.port.unwrap_or(22) + )); + } } } }