use std::fmt::Display;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Row, Table};
use ratatui::Frame;
use seer_core::output::sanitize_line;
use crate::tui::action::{LensData, LensState};
use crate::tui::line_editor::LineEditor;
use crate::tui::theme::Theme;
use crate::tui::widgets::{or_dash, panel};
#[allow(clippy::too_many_arguments)]
pub fn render(
f: &mut Frame,
area: Rect,
theme: &Theme,
domain: Option<&str>,
b: &str,
editing: Option<&LineEditor>,
focused: bool,
state: &LensState,
) {
let inner = panel::render(f, area, theme, "Diff", theme.yellow, focused);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
.split(inner);
let loaded = match state {
LensState::Loaded(LensData::Diff(d)) => Some(d),
_ => None,
};
let a = loaded
.map(|d| d.domain_a.as_str())
.or(domain)
.unwrap_or("(no target)");
let b = loaded.map(|d| d.domain_b.as_str()).unwrap_or(b);
let (b_text, b_color) = match editing {
Some(buf) => (buf.with_caret("▏"), theme.text),
None if !b.is_empty() => (sanitize_line(b), theme.text),
None => ("[ press e ]".to_string(), theme.overlay0),
};
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled("A · ", Style::default().fg(theme.overlay0)),
Span::styled(sanitize_line(a), Style::default().fg(theme.text)),
Span::styled(" ⇄ ", Style::default().fg(theme.yellow)),
Span::styled("B · ", Style::default().fg(theme.overlay0)),
Span::styled(b_text, Style::default().fg(b_color)),
])),
chunks[0],
);
let hint = if focused {
"e edit B · ↵ compare"
} else {
"↵ focus pane"
};
f.render_widget(
Paragraph::new(Line::from(Span::styled(
hint,
Style::default().fg(theme.overlay0),
))),
chunks[1],
);
match state {
LensState::Loaded(LensData::Diff(d)) => comparison_table(f, chunks[2], theme, d),
LensState::Loading => {
f.render_widget(
Paragraph::new(Line::from(Span::styled(
format!("⠋ comparing {a} ⇄ {b}…"),
Style::default().fg(theme.overlay),
))),
chunks[2],
);
}
LensState::Error(msg) => {
f.render_widget(
Paragraph::new(Line::from(Span::styled(
sanitize_line(msg),
Style::default().fg(theme.red),
)))
.wrap(ratatui::widgets::Wrap { trim: false }),
chunks[2],
);
}
_ => {
let idle = if domain.is_some() {
format!("set a second domain (e) to compare against {a}")
} else {
"look up a domain first (/)".to_string()
};
f.render_widget(
Paragraph::new(Line::from(Span::styled(
idle,
Style::default()
.fg(theme.overlay0)
.add_modifier(Modifier::ITALIC),
))),
chunks[2],
);
}
}
}
fn pair<T: Display>((a, b): &(Option<T>, Option<T>)) -> (String, String) {
(
sanitize_line(&or_dash(a.as_ref())),
sanitize_line(&or_dash(b.as_ref())),
)
}
fn comparison_table(f: &mut Frame, area: Rect, theme: &Theme, d: &seer_core::diff::DomainDiff) {
let error_rows = u16::try_from(d.errors.len()).unwrap_or(u16::MAX);
let [area, errors_area] = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(error_rows)])
.areas(area);
let errors: Vec<Line> = d
.errors
.iter()
.map(|e| {
Line::from(Span::styled(
format!("! {}", sanitize_line(e)),
Style::default().fg(theme.red),
))
})
.collect();
f.render_widget(Paragraph::new(errors), errors_area);
let (reg, dns, ssl) = (&d.registration, &d.dns, &d.ssl);
let joined = |(a, b): &(Vec<String>, Vec<String>)| {
(sanitize_line(&a.join(", ")), sanitize_line(&b.join(", ")))
};
let raw = [
("registrar", pair(®.registrar)),
("organization", pair(®.organization)),
("created", pair(®.created)),
("expires", pair(®.expires)),
("A records", joined(&dns.a_records)),
("nameservers", joined(&dns.nameservers)),
("resolves", pair(&dns.resolves)),
("ssl issuer", pair(&ssl.issuer)),
("ssl valid until", pair(&ssl.valid_until)),
("ssl days", pair(&ssl.days_remaining)),
("ssl ok", pair(&ssl.is_valid)),
];
let rows: Vec<Row> = raw
.iter()
.map(|(field, (a_val, b_val))| {
let same = a_val == b_val;
let indicator = if same { "=" } else { "≠" };
let value_color = if same { theme.text } else { theme.yellow };
Row::new(vec![
ratatui::text::Text::from(Line::from(Span::styled(
format!("{indicator} {field:<16}"),
Style::default().fg(if same { theme.overlay0 } else { theme.yellow }),
))),
ratatui::text::Text::from(Line::from(Span::styled(
a_val.clone(),
Style::default().fg(value_color),
))),
ratatui::text::Text::from(Line::from(Span::styled(
b_val.clone(),
Style::default().fg(value_color),
))),
])
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(20),
Constraint::Percentage(40),
Constraint::Percentage(40),
],
)
.column_spacing(1);
f.render_widget(table, area);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::test_util::render_text;
use seer_core::diff::{DnsDiff, DomainDiff, RegistrationDiff, SslDiff};
fn diff_fixture() -> DomainDiff {
DomainDiff {
domain_a: "a.com".into(),
domain_b: "b.com".into(),
registration: RegistrationDiff {
registrar: (Some("NameCheap".into()), Some("GoDaddy".into())),
organization: (None, None),
created: (None, None),
expires: (None, None),
},
dns: DnsDiff {
a_records: (vec!["1.2.3.4".into()], vec!["5.6.7.8".into()]),
nameservers: (vec![], vec![]),
resolves: (Some(true), Some(true)),
},
ssl: SslDiff {
issuer: (None, None),
valid_until: (None, None),
days_remaining: (None, None),
is_valid: (None, None),
},
errors: Vec::new(),
}
}
#[test]
fn failed_checks_are_listed() {
let theme = Theme::frappe();
let mut diff = diff_fixture();
diff.errors = vec!["b.com: status check failed: HTTP request failed".into()];
let state = LensState::Loaded(LensData::Diff(Box::new(diff)));
let text = render_text(100, 24, |f| {
render(
f,
f.area(),
&theme,
Some("a.com"),
"b.com",
None,
false,
&state,
);
});
assert!(
text.contains("b.com: status check failed"),
"errors are shown: {text}"
);
}
#[test]
fn idle_shows_input_bar_with_domain_a() {
let theme = Theme::frappe();
let text = render_text(90, 20, |f| {
render(
f,
f.area(),
&theme,
Some("acme.io"),
"",
None,
false,
&LensState::Idle,
);
});
assert!(text.contains("A ·"), "input bar shows A");
assert!(text.contains("B ·"), "input bar shows B");
assert!(text.contains("acme.io"), "shows current domain as A");
assert!(text.contains("press e"), "prompts how to set B");
}
#[test]
fn editing_shows_live_buffer() {
let theme = Theme::frappe();
let text = render_text(90, 20, |f| {
render(
f,
f.area(),
&theme,
Some("acme.io"),
"",
Some(&LineEditor::from("typed.io")),
true,
&LensState::Idle,
);
});
assert!(text.contains("typed.io"), "live edit buffer should render");
}
#[test]
fn editing_caret_follows_the_cursor() {
let theme = Theme::frappe();
let mut editor = LineEditor::from("typed.io");
editor.home();
let text = render_text(90, 20, |f| {
render(
f,
f.area(),
&theme,
Some("acme.io"),
"",
Some(&editor),
true,
&LensState::Idle,
);
});
assert!(
text.contains("B · ▏typed.io"),
"caret must render at the cursor (Home), not the end"
);
}
#[test]
fn loaded_input_bar_names_the_results_domains() {
let theme = Theme::frappe();
let state = LensState::Loaded(LensData::Diff(Box::new(diff_fixture())));
let text = render_text(90, 20, |f| {
render(
f,
f.area(),
&theme,
Some("session.com"),
"other.com",
None,
false,
&state,
);
});
assert!(text.contains("A · a.com"), "got: {text}");
assert!(text.contains("B · b.com"), "got: {text}");
assert!(!text.contains("session.com"), "got: {text}");
}
#[test]
fn loaded_shows_comparison_table() {
let theme = Theme::frappe();
let state = LensState::Loaded(LensData::Diff(Box::new(diff_fixture())));
let text = render_text(90, 20, |f| {
render(
f,
f.area(),
&theme,
Some("a.com"),
"b.com",
None,
false,
&state,
);
});
assert!(text.contains("NameCheap"), "table renders registrar A");
assert!(text.contains("GoDaddy"), "table renders registrar B");
}
}