diff --git a/Cargo.lock b/Cargo.lock index 434151e..40881e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16,6 +16,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "similar", "sourceview5", "tokio", "tracing", @@ -191,6 +192,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -2262,6 +2273,15 @@ dependencies = [ "libc", ] +[[package]] +name = "similar" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f66ca1f7aca2474dc10c942eb22feffc897735f54cd1db90138c2fddb490987" +dependencies = [ + "bstr", +] + [[package]] name = "slab" version = "0.4.12" diff --git a/crates/aetheris-app/Cargo.toml b/crates/aetheris-app/Cargo.toml index 3cc0e6f..37f93ed 100644 --- a/crates/aetheris-app/Cargo.toml +++ b/crates/aetheris-app/Cargo.toml @@ -66,6 +66,7 @@ relm4 = { version = "0.11", features = ["libadwaita", "gnome_50"] } serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" +similar = "3.1" sourceview5 = "0.11" tokio = { version = "1", features = ["io-util", "macros", "net", "rt-multi-thread", "time"] } tracing = "0.1" diff --git a/crates/aetheris-app/src/app.rs b/crates/aetheris-app/src/app.rs index 7a99ab2..bc3ef89 100644 --- a/crates/aetheris-app/src/app.rs +++ b/crates/aetheris-app/src/app.rs @@ -90,6 +90,7 @@ pub(super) struct DetailPane { favorite_button: gtk::Button, terminal_button: gtk::Button, yaml_buffer: sourceview5::Buffer, + yaml_baseline: String, events_list: gtk::ListBox, conditions_list: gtk::ListBox, service_ports_list: gtk::ListBox, @@ -305,6 +306,7 @@ pub enum AppMsg { NodeDrained(u64, Result<(ObjectDetail, usize), String>), ExplainYaml, ApplyYaml, + ConfirmApplyYaml(DetailTarget, String), ObjectApplied(u64, Result), DownloadYaml, SaveYamlTo(PathBuf, String), diff --git a/crates/aetheris-app/src/app/component/detail_pane.rs b/crates/aetheris-app/src/app/component/detail_pane.rs index 8246dc1..cb49d87 100644 --- a/crates/aetheris-app/src/app/component/detail_pane.rs +++ b/crates/aetheris-app/src/app/component/detail_pane.rs @@ -373,6 +373,7 @@ pub(super) fn build(sender: &ComponentSender) -> (DetailPane, gtk::Box) { favorite_button: detail_favorite_button, terminal_button: detail_terminal_button, yaml_buffer: detail_yaml_buffer, + yaml_baseline: String::new(), events_list: detail_events_list, conditions_list: detail_conditions_list, service_ports_list: detail_service_ports_list, diff --git a/crates/aetheris-app/src/app/handler.rs b/crates/aetheris-app/src/app/handler.rs index 71ec236..8fc21c2 100644 --- a/crates/aetheris-app/src/app/handler.rs +++ b/crates/aetheris-app/src/app/handler.rs @@ -199,7 +199,10 @@ impl App { nodes::handle_node_drained_err(self, token, error) } AppMsg::ExplainYaml => mutations::handle_explain_yaml(self, root), - AppMsg::ApplyYaml => mutations::handle_apply_yaml(self, sender), + AppMsg::ApplyYaml => mutations::handle_apply_yaml(self, sender, root), + AppMsg::ConfirmApplyYaml(target, yaml) => { + mutations::handle_confirm_apply_yaml(self, sender, target, yaml) + } AppMsg::ObjectApplied(token, Ok(detail)) => { mutations::handle_object_applied_ok(self, token, detail) } diff --git a/crates/aetheris-app/src/app/handler/mutations.rs b/crates/aetheris-app/src/app/handler/mutations.rs index 9cd6f29..8ebb221 100644 --- a/crates/aetheris-app/src/app/handler/mutations.rs +++ b/crates/aetheris-app/src/app/handler/mutations.rs @@ -1,5 +1,6 @@ use super::super::commands::*; use super::super::utils::*; +use super::super::yaml::{build_apply_yaml_dialog, yaml_diff}; use super::super::*; pub(super) fn handle_object_detail_loaded_ok( @@ -167,11 +168,35 @@ pub(super) fn handle_explain_yaml(app: &mut App, root: &::Root app.show_yaml_explanation(root); } -pub(super) fn handle_apply_yaml(app: &mut App, sender: ComponentSender) { +pub(super) fn handle_apply_yaml( + app: &mut App, + sender: ComponentSender, + root: &::Root, +) { let Some(target) = app.detail.target.clone() else { return; }; let yaml = text_buffer_text(&app.detail.yaml_buffer); + let Some(diff) = yaml_diff(&app.detail.yaml_baseline, &yaml) else { + app.toaster + .add_toast(adw::Toast::new(&tr("No YAML changes to apply."))); + return; + }; + let dialog = build_apply_yaml_dialog(&target, &diff); + let sender = sender.clone(); + dialog.choose(Some(root), gtk::gio::Cancellable::NONE, move |response| { + if response.as_str() == "apply" { + sender.input(AppMsg::ConfirmApplyYaml(target, yaml)); + } + }); +} + +pub(super) fn handle_confirm_apply_yaml( + app: &mut App, + sender: ComponentSender, + target: DetailTarget, + yaml: String, +) { app.detail.request_token = app.detail.request_token.saturating_add(1); let token = app.detail.request_token; app.loading = true; diff --git a/crates/aetheris-app/src/app/methods/detail.rs b/crates/aetheris-app/src/app/methods/detail.rs index 77bd208..51829ac 100644 --- a/crates/aetheris-app/src/app/methods/detail.rs +++ b/crates/aetheris-app/src/app/methods/detail.rs @@ -96,6 +96,7 @@ impl App { .map(|usage| usage.memory.as_str()) .unwrap_or("-"), ); + self.detail.yaml_baseline.clone_from(&detail.yaml); self.detail.yaml_buffer.set_text(&detail.yaml); self.sync_detail_favorite_button(); self.detail.node_unschedulable = detail.node_unschedulable; diff --git a/crates/aetheris-app/src/app/projects.rs b/crates/aetheris-app/src/app/projects.rs index f02fff7..4550860 100644 --- a/crates/aetheris-app/src/app/projects.rs +++ b/crates/aetheris-app/src/app/projects.rs @@ -48,7 +48,7 @@ pub(super) struct ContextNamespaceSelection { } #[derive(Debug, Clone)] -pub(super) struct DetailTarget { +pub(crate) struct DetailTarget { pub(super) context: String, pub(super) resource: ResourceKind, pub(super) namespace: Option, diff --git a/crates/aetheris-app/src/app/yaml.rs b/crates/aetheris-app/src/app/yaml.rs index 0edbdf3..687ee54 100644 --- a/crates/aetheris-app/src/app/yaml.rs +++ b/crates/aetheris-app/src/app/yaml.rs @@ -1,5 +1,7 @@ +mod diff; mod editor; mod explain; +pub(crate) use diff::{build_apply_yaml_dialog, yaml_diff}; pub(crate) use editor::{build_yaml_search_bar, build_yaml_view, setup_yaml_buffer}; pub(crate) use explain::build_yaml_explanation_content; diff --git a/crates/aetheris-app/src/app/yaml/diff.rs b/crates/aetheris-app/src/app/yaml/diff.rs new file mode 100644 index 0000000..cb2e6d1 --- /dev/null +++ b/crates/aetheris-app/src/app/yaml/diff.rs @@ -0,0 +1,205 @@ +use relm4::{adw, gtk}; +use similar::TextDiff; + +use super::super::{ + i18n::{tr, tr_format}, + projects::DetailTarget, +}; +use adw::prelude::*; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DiffLineKind { + Context, + Addition, + Deletion, + Hunk, + Hint, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct DiffLine { + text: String, + kind: DiffLineKind, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct YamlDiff { + lines: Vec, +} + +pub(crate) fn yaml_diff(original: &str, edited: &str) -> Option { + if original == edited { + return None; + } + + let unified = TextDiff::from_lines(original, edited) + .unified_diff() + .context_radius(3) + .to_string(); + let lines = unified + .split_inclusive('\n') + .map(|text| DiffLine { + kind: classify_line(text), + text: text.to_owned(), + }) + .collect(); + + Some(YamlDiff { lines }) +} + +fn classify_line(line: &str) -> DiffLineKind { + if line.starts_with("@@") { + DiffLineKind::Hunk + } else if line.starts_with('+') { + DiffLineKind::Addition + } else if line.starts_with('-') { + DiffLineKind::Deletion + } else if line.starts_with('\\') { + DiffLineKind::Hint + } else { + DiffLineKind::Context + } +} + +pub(crate) fn build_apply_yaml_dialog(target: &DetailTarget, diff: &YamlDiff) -> adw::AlertDialog { + let dialog = adw::AlertDialog::builder() + .heading(tr("Apply YAML changes?")) + .body(tr_format( + "The following changes will be applied to {kind} {name}.", + &[ + ("{kind}", target.resource.kind.clone()), + ("{name}", target.name.clone()), + ], + )) + .build(); + dialog.add_responses(&[("cancel", &tr("Cancel")), ("apply", &tr("Apply"))]); + dialog.set_close_response("cancel"); + dialog.set_default_response(Some("apply")); + dialog.set_response_appearance("apply", adw::ResponseAppearance::Suggested); + + let buffer = gtk::TextBuffer::new(None::<>k::TextTagTable>); + let dark = adw::StyleManager::default().is_dark(); + let mut iter = buffer.start_iter(); + for line in &diff.lines { + if let Some(markup) = line_markup(line, dark) { + buffer.insert_markup(&mut iter, &markup); + } else { + buffer.insert(&mut iter, &line.text); + } + } + + let view = gtk::TextView::builder() + .buffer(&buffer) + .editable(false) + .cursor_visible(false) + .monospace(true) + .wrap_mode(gtk::WrapMode::None) + .left_margin(12) + .right_margin(12) + .top_margin(12) + .bottom_margin(12) + .build(); + view.add_css_class("card"); + + let scrolled = gtk::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Automatic) + .vscrollbar_policy(gtk::PolicyType::Automatic) + .min_content_width(820) + .max_content_width(900) + .min_content_height(360) + .max_content_height(520) + .propagate_natural_width(true) + .propagate_natural_height(true) + .vexpand(true) + .child(&view) + .build(); + dialog.set_extra_child(Some(&scrolled)); + dialog +} + +fn line_markup(line: &DiffLine, dark: bool) -> Option { + let (foreground, background, emphasis) = match (line.kind, dark) { + (DiffLineKind::Context, _) => return None, + (DiffLineKind::Addition, true) => ("#57e389", "#193d2a", "weight=\"bold\""), + (DiffLineKind::Addition, false) => ("#1b8553", "#d9f2e3", "weight=\"bold\""), + (DiffLineKind::Deletion, true) => ("#ff7b63", "#45201d", "weight=\"bold\""), + (DiffLineKind::Deletion, false) => ("#c01c28", "#f8d7da", "weight=\"bold\""), + (DiffLineKind::Hunk, true) => ("#f8e45c", "#443d18", "weight=\"bold\""), + (DiffLineKind::Hunk, false) => ("#7a4e00", "#f9eac2", "weight=\"bold\""), + (DiffLineKind::Hint, true) => ("#9a9996", "transparent", "style=\"italic\""), + (DiffLineKind::Hint, false) => ("#77767b", "transparent", "style=\"italic\""), + }; + let text = gtk::glib::markup_escape_text(&line.text); + if background == "transparent" { + Some(format!( + "{text}" + )) + } else { + Some(format!( + "{text}" + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn lines(diff: &YamlDiff, kind: DiffLineKind) -> Vec<&str> { + diff.lines + .iter() + .filter(|line| line.kind == kind) + .map(|line| line.text.as_str()) + .collect() + } + + #[test] + fn identical_yaml_has_no_diff() { + assert!(yaml_diff("kind: Pod\n", "kind: Pod\n").is_none()); + } + + #[test] + fn classifies_additions_deletions_and_hunks() { + let diff = yaml_diff("spec:\n replicas: 1\n", "spec:\n replicas: 2\n") + .expect("changed YAML must produce a diff"); + assert!(lines(&diff, DiffLineKind::Hunk)[0].starts_with("@@")); + assert_eq!(lines(&diff, DiffLineKind::Deletion), ["- replicas: 1\n"]); + assert_eq!(lines(&diff, DiffLineKind::Addition), ["+ replicas: 2\n"]); + } + + #[test] + fn keeps_three_context_lines_and_multiple_hunks() { + let original = (1..=14) + .map(|line| format!("key{line}: old\n")) + .collect::(); + let edited = original + .replace("key2: old", "key2: new") + .replace("key13: old", "key13: new"); + let diff = yaml_diff(&original, &edited).expect("changed YAML must produce a diff"); + assert_eq!(lines(&diff, DiffLineKind::Hunk).len(), 2); + assert!(!diff.lines.iter().any(|line| line.text.contains("key7:"))); + } + + #[test] + fn preserves_unicode_and_reports_missing_newline() { + let diff = yaml_diff("name: café\n", "name: foguete 🚀") + .expect("changed YAML must produce a diff"); + assert!(lines(&diff, DiffLineKind::Addition)[0].contains("foguete 🚀")); + assert!(!lines(&diff, DiffLineKind::Hint).is_empty()); + } + + #[test] + fn markup_has_explicit_theme_colors_and_escapes_yaml_text() { + let line = DiffLine { + text: "+ value: \n".to_owned(), + kind: DiffLineKind::Addition, + }; + let dark = line_markup(&line, true).expect("addition must have markup"); + let light = line_markup(&line, false).expect("addition must have markup"); + assert!(dark.contains("foreground=\"#57e389\"")); + assert!(dark.contains("background=\"#193d2a\"")); + assert!(light.contains("foreground=\"#1b8553\"")); + assert!(light.contains("background=\"#d9f2e3\"")); + assert!(dark.contains("<changed>")); + } +}