diff --git a/Cargo.lock b/Cargo.lock index db9bef5..434151e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,6 +21,7 @@ dependencies = [ "tracing", "tracing-subscriber", "vte4", + "zcolorizer", ] [[package]] @@ -458,7 +459,7 @@ dependencies = [ "cc", "memchr", "rustc_version", - "toml", + "toml 1.1.2+spec-1.1.0", "vswhom", "winreg", ] @@ -1758,7 +1759,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit", + "toml_edit 0.25.12+spec-1.1.0", ] [[package]] @@ -2183,6 +2184,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -2356,7 +2366,7 @@ dependencies = [ "cfg-expr", "heck", "pkg-config", - "toml", + "toml 1.1.2+spec-1.1.0", "version-compare", ] @@ -2464,6 +2474,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + [[package]] name = "toml" version = "1.1.2+spec-1.1.0" @@ -2472,11 +2494,20 @@ checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ "indexmap", "serde_core", - "serde_spanned", - "toml_datetime", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.3", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", ] [[package]] @@ -2488,6 +2519,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + [[package]] name = "toml_edit" version = "0.25.12+spec-1.1.0" @@ -2495,9 +2540,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.3", ] [[package]] @@ -2506,9 +2551,15 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.3", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "toml_writer" version = "1.1.1+spec-1.1.0" @@ -2943,6 +2994,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "1.0.3" @@ -2968,6 +3028,21 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "zcolorizer" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f67c819b8a5e9344c9b6eef702fd3c8519751ad08f6fdba582e3177105772d5" +dependencies = [ + "dirs", + "once_cell", + "regex", + "serde", + "serde_json", + "thiserror", + "toml 0.8.23", +] + [[package]] name = "zerocopy" version = "0.8.52" diff --git a/crates/aetheris-app/Cargo.toml b/crates/aetheris-app/Cargo.toml index a888de0..3cc0e6f 100644 --- a/crates/aetheris-app/Cargo.toml +++ b/crates/aetheris-app/Cargo.toml @@ -70,6 +70,7 @@ sourceview5 = "0.11" tokio = { version = "1", features = ["io-util", "macros", "net", "rt-multi-thread", "time"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +zcolorizer = { version = "=0.2.2", default-features = false } [target.'cfg(not(target_os = "windows"))'.dependencies] vte4 = "0.10" diff --git a/crates/aetheris-app/src/app.rs b/crates/aetheris-app/src/app.rs index 946a84d..7a99ab2 100644 --- a/crates/aetheris-app/src/app.rs +++ b/crates/aetheris-app/src/app.rs @@ -2,6 +2,7 @@ use std::{ collections::{BTreeSet, HashMap, VecDeque}, fs, path::PathBuf, + sync::Arc, }; use aetheris_kube::{ @@ -20,13 +21,13 @@ use sourceview5::prelude::*; #[cfg(not(target_os = "windows"))] use vte4::prelude::*; -mod ansi; mod commands; mod component; mod dialogs; mod handler; pub(crate) mod i18n; mod layout; +mod log_highlighting; mod methods; mod object_detail; mod projects; @@ -37,6 +38,7 @@ mod widgets; mod yaml; use i18n::{tr, tr_format, trn}; +use log_highlighting::{HighlightedLine, LogHighlighter}; use projects::{ DetailTarget, ObjectColumn, ObjectFavorite, ObjectTableColumn, PodLogTarget, Project, ProjectStore, ResourceSection, StatusFilter, @@ -195,6 +197,7 @@ pub struct App { log_streaming: bool, log_stream_token: u64, log_abort_handle: Option, + log_highlighter: Arc, exec_token: u64, terminal_sessions: HashMap, port_forwarding: bool, @@ -314,7 +317,7 @@ pub enum AppMsg { StopPodLogs, ClearPodLogs, ToggleDetailOverview, - PodLogLine(u64, String), + PodLogLine(u64, HighlightedLine), PodLogFinished(u64, Result<(), String>), ShowPodTerminal, RestartPodTerminal(u64), diff --git a/crates/aetheris-app/src/app/ansi.rs b/crates/aetheris-app/src/app/ansi.rs deleted file mode 100644 index e917d59..0000000 --- a/crates/aetheris-app/src/app/ansi.rs +++ /dev/null @@ -1,152 +0,0 @@ -use super::yaml::ensure_text_tag; -use super::*; - -const ESC: char = '\u{1b}'; - -pub(super) fn setup_log_highlighting(buffer: >k::TextBuffer) { - ensure_text_tag(buffer, "ansi-bold", &[("weight", &700)]); - ensure_text_tag(buffer, "ansi-fg-black", &[("foreground", &"#9a9996")]); - ensure_text_tag(buffer, "ansi-fg-red", &[("foreground", &"#e01b24")]); - ensure_text_tag(buffer, "ansi-fg-green", &[("foreground", &"#2ec27e")]); - ensure_text_tag(buffer, "ansi-fg-yellow", &[("foreground", &"#e5a50a")]); - ensure_text_tag(buffer, "ansi-fg-blue", &[("foreground", &"#3584e4")]); - ensure_text_tag(buffer, "ansi-fg-magenta", &[("foreground", &"#c061cb")]); - ensure_text_tag(buffer, "ansi-fg-cyan", &[("foreground", &"#0891b2")]); - ensure_text_tag(buffer, "ansi-fg-white", &[("foreground", &"#f6f5f4")]); -} - -pub(super) fn insert_ansi_line(buffer: >k::TextBuffer, line: &str) { - let mut iter = buffer.end_iter(); - for (text, tags) in parse_ansi_line(line) { - let start_offset = iter.offset(); - buffer.insert(&mut iter, &text); - if !tags.is_empty() { - let start_iter = buffer.iter_at_offset(start_offset); - for tag in tags { - buffer.apply_tag_by_name(tag, &start_iter, &iter); - } - } - } -} - -fn parse_ansi_line(line: &str) -> Vec<(String, Vec<&'static str>)> { - let mut segments = Vec::new(); - let mut current_text = String::new(); - let mut active_tags: Vec<&'static str> = Vec::new(); - let mut chars = line.chars().peekable(); - - while let Some(ch) = chars.next() { - if ch != ESC || chars.peek() != Some(&'[') { - current_text.push(ch); - continue; - } - chars.next(); - - let mut code = String::new(); - let mut terminator = None; - for next in chars.by_ref() { - if next.is_ascii_digit() || next == ';' { - code.push(next); - } else { - terminator = Some(next); - break; - } - } - - if !current_text.is_empty() { - segments.push((std::mem::take(&mut current_text), active_tags.clone())); - } - if terminator == Some('m') { - apply_sgr_codes(&code, &mut active_tags); - } - } - - if !current_text.is_empty() { - segments.push((current_text, active_tags)); - } - - segments -} - -fn apply_sgr_codes(code: &str, active_tags: &mut Vec<&'static str>) { - if code.is_empty() { - active_tags.clear(); - return; - } - for part in code.split(';') { - match part.parse::() { - Ok(0) => active_tags.clear(), - Ok(1) => push_unique(active_tags, "ansi-bold"), - Ok(21) | Ok(22) => active_tags.retain(|tag| *tag != "ansi-bold"), - Ok(code @ (30..=37 | 90..=97)) => { - active_tags.retain(|tag| !tag.starts_with("ansi-fg-")); - active_tags.push(fg_tag(code % 10)); - } - Ok(39) => active_tags.retain(|tag| !tag.starts_with("ansi-fg-")), - _ => {} - } - } -} - -fn push_unique(active_tags: &mut Vec<&'static str>, tag: &'static str) { - if !active_tags.contains(&tag) { - active_tags.push(tag); - } -} - -fn fg_tag(index: u32) -> &'static str { - match index { - 0 => "ansi-fg-black", - 1 => "ansi-fg-red", - 2 => "ansi-fg-green", - 3 => "ansi-fg-yellow", - 4 => "ansi-fg-blue", - 5 => "ansi-fg-magenta", - 6 => "ansi-fg-cyan", - _ => "ansi-fg-white", - } -} - -#[cfg(test)] -mod tests { - use super::parse_ansi_line; - - #[test] - fn parse_ansi_line_strips_escape_codes_from_plain_text() { - let segments = parse_ansi_line("no colors here"); - assert_eq!(segments, vec![(String::from("no colors here"), vec![])]); - } - - #[test] - fn parse_ansi_line_extracts_a_single_color() { - let segments = parse_ansi_line("\u{1b}[32mINFO\u{1b}[0m starting up"); - assert_eq!( - segments, - vec![ - (String::from("INFO"), vec!["ansi-fg-green"]), - (String::from(" starting up"), vec![]), - ] - ); - } - - #[test] - fn parse_ansi_line_combines_bold_and_color_in_one_sequence() { - let segments = parse_ansi_line("\u{1b}[1;31mERROR\u{1b}[0m failed"); - assert_eq!( - segments, - vec![ - (String::from("ERROR"), vec!["ansi-bold", "ansi-fg-red"]), - (String::from(" failed"), vec![]), - ] - ); - } - - #[test] - fn parse_ansi_line_maps_bright_colors_to_the_base_tag() { - let segments = parse_ansi_line("\u{1b}[93mWARN\u{1b}[0m"); - assert_eq!( - segments, - vec![(String::from("WARN"), vec!["ansi-fg-yellow"])] - ); - } -} diff --git a/crates/aetheris-app/src/app/commands.rs b/crates/aetheris-app/src/app/commands.rs index 39977c9..74d7b6b 100644 --- a/crates/aetheris-app/src/app/commands.rs +++ b/crates/aetheris-app/src/app/commands.rs @@ -159,12 +159,14 @@ pub(super) async fn stream_pod_logs( context: String, request: PodLogRequest, token: u64, + highlighter: Arc, out: relm4::Sender, ) -> anyhow::Result<()> { let manager = KubeManager::load()?; let session = manager.connect_context(&context).await?; session .stream_pod_logs(request, move |line| { + let line = highlighter.highlight_line(&line); let _ = out.send(AppMsg::PodLogLine(token, line)); }) .await diff --git a/crates/aetheris-app/src/app/component.rs b/crates/aetheris-app/src/app/component.rs index 534f06c..48c4583 100644 --- a/crates/aetheris-app/src/app/component.rs +++ b/crates/aetheris-app/src/app/component.rs @@ -122,6 +122,10 @@ impl Component for App { .build(); let spinner = gtk::Spinner::builder().spinning(true).visible(true).build(); let (detail, detail_page) = detail_pane::build(&sender); + let log_highlighter = Arc::new(LogHighlighter::new().unwrap_or_else(|error| { + tracing::warn!("Unable to initialize semantic log highlighting: {error}"); + LogHighlighter::disabled() + })); let toaster = adw::ToastOverlay::new(); let root_stack = gtk::Stack::new(); @@ -288,6 +292,7 @@ impl Component for App { log_streaming: false, log_stream_token: 0, log_abort_handle: None, + log_highlighter, exec_token: 0, terminal_sessions: HashMap::new(), port_forwarding: false, diff --git a/crates/aetheris-app/src/app/component/detail_pane.rs b/crates/aetheris-app/src/app/component/detail_pane.rs index 24abde7..8246dc1 100644 --- a/crates/aetheris-app/src/app/component/detail_pane.rs +++ b/crates/aetheris-app/src/app/component/detail_pane.rs @@ -1,4 +1,4 @@ -use super::super::ansi::*; +use super::super::log_highlighting::setup_log_tags; use super::super::object_detail::*; use super::super::widgets::{available_icon_name, related_pods_column_view}; use super::super::yaml::*; @@ -186,8 +186,8 @@ pub(super) fn build(sender: &ComponentSender) -> (DetailPane, gtk::Box) { .hexpand(true) .build(); let detail_log_buffer = gtk::TextBuffer::new(None::<>k::TextTagTable>); - setup_log_highlighting(&detail_log_buffer); let detail_log_view = gtk::TextView::with_buffer(&detail_log_buffer); + setup_log_tags(&detail_log_view, &detail_log_buffer); let detail_port_local_spin = gtk::SpinButton::with_range(0.0, 65535.0, 1.0); detail_port_local_spin.set_numeric(true); detail_port_local_spin.set_value(0.0); diff --git a/crates/aetheris-app/src/app/handler/logs.rs b/crates/aetheris-app/src/app/handler/logs.rs index 7354ca1..8ea35fd 100644 --- a/crates/aetheris-app/src/app/handler/logs.rs +++ b/crates/aetheris-app/src/app/handler/logs.rs @@ -14,7 +14,7 @@ pub(super) fn handle_clear_pod_logs(app: &mut App) { app.detail.log_buffer.set_text(""); } -pub(super) fn handle_pod_log_line(app: &mut App, token: u64, line: String) { +pub(super) fn handle_pod_log_line(app: &mut App, token: u64, line: HighlightedLine) { if token == app.log_stream_token { app.append_log_line(&line); } diff --git a/crates/aetheris-app/src/app/log_highlighting.rs b/crates/aetheris-app/src/app/log_highlighting.rs new file mode 100644 index 0000000..2871b7c --- /dev/null +++ b/crates/aetheris-app/src/app/log_highlighting.rs @@ -0,0 +1,428 @@ +use std::borrow::Cow; + +use relm4::adw; +use relm4::gtk; +use relm4::gtk::prelude::*; +use zcolorizer::config::Config; +use zcolorizer::rules::{Rule, compile_all}; + +const MAX_HIGHLIGHT_BYTES: usize = 64 * 1024; +const LOG_MODULES: &[&str] = &["json", "logfmt", "spring", "python", "rust-log", "rails"]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LogStyleRole { + Error, + Warning, + Success, + Info, + Debug, + Metadata, + Identity, + Numeric, + TextValue, + Link, + Keyword, + Key, +} + +impl LogStyleRole { + const ALL: [Self; 12] = [ + Self::Error, + Self::Warning, + Self::Success, + Self::Info, + Self::Debug, + Self::Metadata, + Self::Identity, + Self::Numeric, + Self::TextValue, + Self::Link, + Self::Keyword, + Self::Key, + ]; + + const fn tag_name(self) -> &'static str { + match self { + Self::Error => "log-error", + Self::Warning => "log-warning", + Self::Success => "log-success", + Self::Info => "log-info", + Self::Debug => "log-debug", + Self::Metadata => "log-metadata", + Self::Identity => "log-identity", + Self::Numeric => "log-numeric", + Self::TextValue => "log-text-value", + Self::Link => "log-link", + Self::Keyword => "log-keyword", + Self::Key => "log-key", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct HighlightedSpan { + pub start: usize, + pub end: usize, + pub role: LogStyleRole, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct HighlightedLine { + pub text: String, + pub spans: Vec, +} + +#[derive(Debug)] +pub(super) struct LogHighlighter { + rules: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Owner { + Unclaimed, + Plain, + Styled(LogStyleRole), +} + +impl LogHighlighter { + pub(super) fn new() -> zcolorizer::Result { + debug_assert!( + LOG_MODULES + .iter() + .all(|name| zcolorizer::modules::get(name).is_some()) + ); + let config = Config { + modules: LOG_MODULES.iter().map(|name| (*name).to_string()).collect(), + ..Config::default() + }; + Ok(Self { + rules: compile_all(&config.resolve_rule_defs())?, + }) + } + + pub(super) const fn disabled() -> Self { + Self { rules: Vec::new() } + } + + pub(super) fn highlight_line(&self, line: &str) -> HighlightedLine { + let text = sanitize(line).into_owned(); + if text.is_empty() || text.len() > MAX_HIGHLIGHT_BYTES || self.rules.is_empty() { + return HighlightedLine { + text, + spans: Vec::new(), + }; + } + + let mut owners = vec![Owner::Unclaimed; text.len()]; + for rule in &self.rules { + if rule.has_named_groups { + claim_named_rule(&mut owners, rule, &text); + } else { + claim_whole_rule(&mut owners, rule, &text); + } + } + + HighlightedLine { + spans: styled_spans(&owners), + text, + } + } +} + +fn claim_named_rule(owners: &mut [Owner], rule: &Rule, text: &str) { + for captures in rule.regex.captures_iter(text) { + for (index, token) in rule.group_tokens.iter().enumerate() { + let (Some(token), Some(found)) = (token, captures.get(index)) else { + continue; + }; + claim(owners, found.start(), found.end(), style_role(token)); + } + } +} + +fn claim_whole_rule(owners: &mut [Owner], rule: &Rule, text: &str) { + for found in rule.regex.find_iter(text) { + claim( + owners, + found.start(), + found.end(), + style_role(&rule.whole_token), + ); + } +} + +fn claim(owners: &mut [Owner], start: usize, end: usize, role: Option) { + let owner = role.map_or(Owner::Plain, Owner::Styled); + for slot in &mut owners[start..end] { + if *slot == Owner::Unclaimed { + *slot = owner; + } + } +} + +fn styled_spans(owners: &[Owner]) -> Vec { + let mut spans = Vec::new(); + let mut index = 0; + while index < owners.len() { + let Owner::Styled(role) = owners[index] else { + index += 1; + continue; + }; + let start = index; + index += 1; + while index < owners.len() && owners[index] == Owner::Styled(role) { + index += 1; + } + spans.push(HighlightedSpan { + start, + end: index, + role, + }); + } + spans +} + +fn style_role(token: &str) -> Option { + match token { + "error" | "bad" => Some(LogStyleRole::Error), + "warning" => Some(LogStyleRole::Warning), + "good" => Some(LogStyleRole::Success), + "info" => Some(LogStyleRole::Info), + "debug" | "unknown" => Some(LogStyleRole::Debug), + "date" | "time" | "duration" | "latency" => Some(LogStyleRole::Metadata), + "process" | "pid" | "thread" | "facility" | "host" | "service" | "user" => { + Some(LogStyleRole::Identity) + } + "number" | "size" | "percentage" | "address" | "ip" | "mac" | "hash" | "http_code" + | "version" => Some(LogStyleRole::Numeric), + "string" | "file" | "dir" => Some(LogStyleRole::TextValue), + "uri" | "email" => Some(LogStyleRole::Link), + "http_method" | "protocol" | "keyword" | "signal" | "system" | "level" => { + Some(LogStyleRole::Keyword) + } + "json_key" | "field" | "bracket" => Some(LogStyleRole::Key), + _ => None, + } +} + +fn sanitize(line: &str) -> Cow<'_, str> { + let is_control = |ch: char| { + let code = ch as u32; + (code < 0x20 && ch != '\t') || code == 0x7f || (0x80..=0x9f).contains(&code) + }; + if !line.chars().any(is_control) { + return Cow::Borrowed(line); + } + + let mut output = String::with_capacity(line.len()); + let mut chars = line.chars().peekable(); + while let Some(ch) = chars.next() { + match ch { + '\u{1b}' => match chars.peek() { + Some('[') => { + chars.next(); + for next in chars.by_ref() { + if ('\u{40}'..='\u{7e}').contains(&next) { + break; + } + } + } + Some(']') => { + chars.next(); + while let Some(next) = chars.next() { + if next == '\u{07}' { + break; + } + if next == '\u{1b}' && chars.peek() == Some(&'\\') { + chars.next(); + break; + } + } + } + Some(_) => { + chars.next(); + } + None => {} + }, + '\t' => output.push('\t'), + ch if is_control(ch) => {} + ch => output.push(ch), + } + } + Cow::Owned(output) +} + +pub(super) fn setup_log_tags(view: >k::TextView, buffer: >k::TextBuffer) { + for role in LogStyleRole::ALL { + if buffer.tag_table().lookup(role.tag_name()).is_none() { + let _ = buffer.create_tag(Some(role.tag_name()), &[]); + } + } + refresh_log_tags(view, buffer); + + let view = view.downgrade(); + let buffer = buffer.downgrade(); + adw::StyleManager::default().connect_dark_notify(move |_| { + if let (Some(view), Some(buffer)) = (view.upgrade(), buffer.upgrade()) { + refresh_log_tags(&view, &buffer); + } + }); +} + +fn refresh_log_tags(view: >k::TextView, buffer: >k::TextBuffer) { + let foreground = view.color(); + let (error, warning, success, accent) = if adw::StyleManager::default().is_dark() { + ( + gtk::gdk::RGBA::new(1.0, 0.48, 0.39, 1.0), + gtk::gdk::RGBA::new(0.97, 0.89, 0.36, 1.0), + gtk::gdk::RGBA::new(0.34, 0.89, 0.54, 1.0), + gtk::gdk::RGBA::new(0.47, 0.68, 0.93, 1.0), + ) + } else { + ( + gtk::gdk::RGBA::new(0.75, 0.11, 0.16, 1.0), + gtk::gdk::RGBA::new(0.56, 0.35, 0.01, 1.0), + gtk::gdk::RGBA::new(0.15, 0.64, 0.41, 1.0), + gtk::gdk::RGBA::new(0.11, 0.44, 0.85, 1.0), + ) + }; + let mut dim = foreground; + dim.set_alpha(0.65); + + for role in LogStyleRole::ALL { + let Some(tag) = buffer.tag_table().lookup(role.tag_name()) else { + continue; + }; + let color = match role { + LogStyleRole::Error => &error, + LogStyleRole::Warning => &warning, + LogStyleRole::Success | LogStyleRole::TextValue => &success, + LogStyleRole::Link => &accent, + LogStyleRole::Debug => &dim, + LogStyleRole::Info + | LogStyleRole::Metadata + | LogStyleRole::Numeric + | LogStyleRole::Keyword => &accent, + LogStyleRole::Identity | LogStyleRole::Key => &foreground, + }; + tag.set_foreground_rgba(Some(color)); + tag.set_weight( + if matches!( + role, + LogStyleRole::Error + | LogStyleRole::Warning + | LogStyleRole::Success + | LogStyleRole::Identity + | LogStyleRole::Keyword + | LogStyleRole::Key + ) { + 700 + } else { + 400 + }, + ); + tag.set_style(if role == LogStyleRole::Debug { + gtk::pango::Style::Italic + } else { + gtk::pango::Style::Normal + }); + tag.set_underline(if role == LogStyleRole::Link { + gtk::pango::Underline::Single + } else { + gtk::pango::Underline::None + }); + } +} + +pub(super) fn insert_highlighted_line(buffer: >k::TextBuffer, line: &HighlightedLine) { + let mut iter = buffer.end_iter(); + let mut cursor = 0; + for span in &line.spans { + if cursor < span.start { + buffer.insert(&mut iter, &line.text[cursor..span.start]); + } + let start_offset = iter.offset(); + buffer.insert(&mut iter, &line.text[span.start..span.end]); + let start = buffer.iter_at_offset(start_offset); + buffer.apply_tag_by_name(span.role.tag_name(), &start, &iter); + cursor = span.end; + } + if cursor < line.text.len() { + buffer.insert(&mut iter, &line.text[cursor..]); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn highlighter() -> LogHighlighter { + LogHighlighter::new().expect("builtin zcolorizer rules must compile") + } + + fn styled_text(line: &HighlightedLine, role: LogStyleRole) -> Vec<&str> { + line.spans + .iter() + .filter(|span| span.role == role) + .map(|span| &line.text[span.start..span.end]) + .collect() + } + + #[test] + fn highlights_severity_and_metadata() { + let line = highlighter() + .highlight_line("2026-08-21T12:30:45Z WARN api[42]: request failed after 15ms"); + assert!(styled_text(&line, LogStyleRole::Warning).contains(&"WARN")); + assert!(styled_text(&line, LogStyleRole::Metadata).contains(&"2026-08-21")); + assert!(styled_text(&line, LogStyleRole::Identity).contains(&"api")); + } + + #[test] + fn structured_modules_win_over_generic_rules() { + let line = highlighter() + .highlight_line(r#"{"level":"error","message":"failed","status":500,"ok":false}"#); + assert!(styled_text(&line, LogStyleRole::Key).contains(&"\"level\"")); + assert!(styled_text(&line, LogStyleRole::Numeric).contains(&"500")); + assert!( + styled_text(&line, LogStyleRole::Error) + .iter() + .any(|text| text.contains("error")) + ); + } + + #[test] + fn supports_logfmt_and_framework_lines() { + let logfmt = highlighter().highlight_line("level=info method=GET status=200 duration=12ms"); + assert!(styled_text(&logfmt, LogStyleRole::Info).contains(&"info")); + assert!(styled_text(&logfmt, LogStyleRole::Numeric).contains(&"200")); + + let python = highlighter() + .highlight_line("2026-08-21 12:30:45,123 - worker.api - ERROR - request failed"); + assert!(styled_text(&python, LogStyleRole::Error).contains(&"ERROR")); + } + + #[test] + fn strips_terminal_sequences_and_preserves_unicode() { + let line = highlighter().highlight_line("\u{1b}[31mERRO café 🚀 42\u{1b}[0m\u{07}"); + assert_eq!(line.text, "ERRO café 🚀 42"); + for span in &line.spans { + assert!(line.text.is_char_boundary(span.start)); + assert!(line.text.is_char_boundary(span.end)); + } + } + + #[test] + fn long_lines_are_sanitized_but_not_classified() { + let input = format!("ERROR {}", "x".repeat(MAX_HIGHLIGHT_BYTES)); + let line = highlighter().highlight_line(&input); + assert_eq!(line.text, input); + assert!(line.spans.is_empty()); + } + + #[test] + fn disabled_highlighter_falls_back_to_plain_sanitized_text() { + let line = LogHighlighter::disabled().highlight_line("\u{1b}[32mplain\u{1b}[0m"); + assert_eq!(line.text, "plain"); + assert!(line.spans.is_empty()); + } +} diff --git a/crates/aetheris-app/src/app/streams/logs.rs b/crates/aetheris-app/src/app/streams/logs.rs index 9d7f045..1b03dca 100644 --- a/crates/aetheris-app/src/app/streams/logs.rs +++ b/crates/aetheris-app/src/app/streams/logs.rs @@ -1,5 +1,5 @@ -use super::super::ansi::*; use super::super::commands::*; +use super::super::log_highlighting::insert_highlighted_line; use super::super::utils::*; use super::super::*; @@ -41,6 +41,7 @@ impl App { tail_lines: Some(if follow { 200 } else { 500 }), }; let context = target.context; + let highlighter = Arc::clone(&self.log_highlighter); let (abort_handle, abort_registration) = AbortHandle::new_pair(); self.log_abort_handle = Some(abort_handle); self.log_streaming = true; @@ -51,9 +52,10 @@ impl App { .register( Abortable::new( async move { - let result = stream_pod_logs(context, request, token, out.clone()) - .await - .map_err(format_error); + let result = + stream_pod_logs(context, request, token, highlighter, out.clone()) + .await + .map_err(format_error); let _ = out.send(AppMsg::PodLogFinished(token, result)); }, abort_registration, @@ -153,8 +155,8 @@ impl App { self.detail.log_status_label.set_label(&status); } - pub(crate) fn append_log_line(&self, line: &str) { - insert_ansi_line(&self.detail.log_buffer, line); + pub(crate) fn append_log_line(&self, line: &HighlightedLine) { + insert_highlighted_line(&self.detail.log_buffer, line); let mut iter = self.detail.log_buffer.end_iter(); self.detail.log_buffer.insert(&mut iter, "\n"); let mark = diff --git a/crates/aetheris-app/src/app/yaml.rs b/crates/aetheris-app/src/app/yaml.rs index ad79b52..0edbdf3 100644 --- a/crates/aetheris-app/src/app/yaml.rs +++ b/crates/aetheris-app/src/app/yaml.rs @@ -1,7 +1,5 @@ mod editor; mod explain; -pub(crate) use editor::{ - build_yaml_search_bar, build_yaml_view, ensure_text_tag, setup_yaml_buffer, -}; +pub(crate) use editor::{build_yaml_search_bar, build_yaml_view, setup_yaml_buffer}; pub(crate) use explain::build_yaml_explanation_content;