Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/aetheris-app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions crates/aetheris-app/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -305,6 +306,7 @@ pub enum AppMsg {
NodeDrained(u64, Result<(ObjectDetail, usize), String>),
ExplainYaml,
ApplyYaml,
ConfirmApplyYaml(DetailTarget, String),
ObjectApplied(u64, Result<ObjectDetail, String>),
DownloadYaml,
SaveYamlTo(PathBuf, String),
Expand Down
1 change: 1 addition & 0 deletions crates/aetheris-app/src/app/component/detail_pane.rs
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,7 @@ pub(super) fn build(sender: &ComponentSender<App>) -> (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,
Expand Down
5 changes: 4 additions & 1 deletion crates/aetheris-app/src/app/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
27 changes: 26 additions & 1 deletion crates/aetheris-app/src/app/handler/mutations.rs
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -167,11 +168,35 @@ pub(super) fn handle_explain_yaml(app: &mut App, root: &<App as Component>::Root
app.show_yaml_explanation(root);
}

pub(super) fn handle_apply_yaml(app: &mut App, sender: ComponentSender<App>) {
pub(super) fn handle_apply_yaml(
app: &mut App,
sender: ComponentSender<App>,
root: &<App as Component>::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<App>,
target: DetailTarget,
yaml: String,
) {
app.detail.request_token = app.detail.request_token.saturating_add(1);
let token = app.detail.request_token;
app.loading = true;
Expand Down
1 change: 1 addition & 0 deletions crates/aetheris-app/src/app/methods/detail.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion crates/aetheris-app/src/app/projects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
Expand Down
2 changes: 2 additions & 0 deletions crates/aetheris-app/src/app/yaml.rs
Original file line number Diff line number Diff line change
@@ -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;
205 changes: 205 additions & 0 deletions crates/aetheris-app/src/app/yaml/diff.rs
Original file line number Diff line number Diff line change
@@ -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<DiffLine>,
}

pub(crate) fn yaml_diff(original: &str, edited: &str) -> Option<YamlDiff> {
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::<&gtk::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<String> {
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!(
"<span foreground=\"{foreground}\" {emphasis}>{text}</span>"
))
} else {
Some(format!(
"<span foreground=\"{foreground}\" background=\"{background}\" {emphasis}>{text}</span>"
))
}
}

#[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::<String>();
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: <changed>\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("&lt;changed&gt;"));
}
}
Loading