diff --git a/crates/app/src/host/message.rs b/crates/app/src/host/message.rs index 3214dc5225..9db1fde12e 100644 --- a/crates/app/src/host/message.rs +++ b/crates/app/src/host/message.rs @@ -1,3 +1,5 @@ +//! Host-service messages consumed by the native UI. + use std::path::PathBuf; use uuid::Uuid; @@ -28,7 +30,9 @@ pub enum HostMessage { MultiSetupClose { id: Uuid }, /// The collected DLT statistics on a file for a SessionSetup DltStatistics { + /// Setup tab that requested the statistics. setup_session_id: Uuid, + /// Collected statistics, or `None` when collection failed. statistics: Option>, }, /// A new session has been successfully created. @@ -43,7 +47,12 @@ pub enum HostMessage { /// Presets loaded from a file and ready for UI-side registry import. PresetsImported(Box), /// Presets were exported successfully to the provided file path. - PresetsExported { path: PathBuf, count: usize }, + PresetsExported { + /// Destination file path. + path: PathBuf, + /// Number of exported presets. + count: usize, + }, /// A newer application version was found by the quiet startup update check. AppVersionUpdate(Box), /// Result of an explicit user-triggered update check. @@ -69,8 +78,19 @@ pub struct PresetsImported { pub path: PathBuf, /// Parsed presets ready to be inserted into the registry. pub presets: Vec, - /// True when the file was parsed through the legacy compatibility path. - pub used_legacy_format: bool, + /// Import format detected by the backend parser. + pub format: ImportFormat, +} + +/// Import source recognized by the preset parser. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ImportFormat { + /// Native v1 Chipmunk named preset document. + Version1, + /// Native v2 Chipmunk named preset document. + Version2, + /// Legacy Chipmunk V3 TypeScript frontend export. + Legacy, } /// README loading result for a Plugin Manager request. diff --git a/crates/app/src/host/service/mod.rs b/crates/app/src/host/service/mod.rs index 0e306d531f..0a8768bf4e 100644 --- a/crates/app/src/host/service/mod.rs +++ b/crates/app/src/host/service/mod.rs @@ -27,7 +27,7 @@ use crate::{ common::{dlt_stats::dlt_statistics, parsers::ParserNames, sources::StreamNames}, communication::ServiceHandle, error::HostError, - message::{HostMessage, PluginReadmeLoaded, PresetsImported}, + message::{HostMessage, ImportFormat, PluginReadmeLoaded, PresetsImported}, notification::AppNotification, service::storage::recent::RecentSessionOpenRequest, ui::{ @@ -52,7 +52,7 @@ use crate::{ }; use plugin::{PluginEvent, PluginService}; -use presets_io::{ImportFormat, import_named_presets, serialize_named_presets}; +use presets_io::{import_named_presets, serialize_named_presets}; use storage::StorageService; pub mod file; @@ -822,7 +822,7 @@ impl HostService { }) })??; - let used_legacy_format = match report.format { + match report.format { ImportFormat::Legacy => { for warning in &report.warnings { trace!( @@ -831,17 +831,16 @@ impl HostService { warning ); } - true } - ImportFormat::Version1 => false, - }; + ImportFormat::Version1 | ImportFormat::Version2 => {} + } self.communication .senders .send_message(HostMessage::PresetsImported(Box::new(PresetsImported { path, presets: report.presets, - used_legacy_format, + format: report.format, }))) .await; diff --git a/crates/app/src/host/service/presets_io.rs b/crates/app/src/host/service/presets_io.rs deleted file mode 100644 index 89fab2d6ce..0000000000 --- a/crates/app/src/host/service/presets_io.rs +++ /dev/null @@ -1,894 +0,0 @@ -//! Service-side import and export for named preset documents. -//! -//! This module owns the native on-disk JSON schema, parses the legacy export -//! shape for backward compatibility, and validates imported filters before the -//! UI applies them into the runtime preset registry. - -use std::fmt; - -use processor::search::filter::SearchFilter; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use uuid::Uuid; - -use crate::{ - common::validation::{ValidationEligibility, validate_filter, validate_search_value_filter}, - host::ui::registry::presets::Preset, -}; - -/// Document kind written during serialization and required during import to -/// recognize preset files. -const DOCUMENT_KIND: &str = "chipmunk_named_presets"; - -/// Document version written during serialization and checked during import for -/// schema compatibility. -const DOCUMENT_VERSION: u8 = 1; - -/// Semantic preset payload stored in the native preset document. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct NamedPreset { - pub name: String, - pub filters: Vec, - pub search_values: Vec, -} - -/// Import source recognized by the preset parser. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ImportFormat { - Version1, - // Legacy from Chipmunk V3 with typescript frontend. - Legacy, -} - -/// Result returned after parsing a preset file successfully. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ImportReport { - pub format: ImportFormat, - pub presets: Vec, - pub warnings: Vec, -} - -/// Non-fatal issues collected while translating a legacy preset export. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ImportWarning { - LegacyCollectionSkipped { - collection_name: Option, - reason: String, - }, - LegacyEntryIgnored { - preset_name: String, - entry_kind: LegacyEntryKind, - count: usize, - }, -} - -/// Legacy entry kind that was ignored during translation. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum LegacyEntryKind { - Bookmark, - InvalidFilter, - InvalidChart, - Unsupported(String), -} - -/// Versioned preset document stored on disk. -#[derive(Debug, Serialize, Deserialize)] -struct Document { - kind: String, - version: u8, - presets: Vec, -} - -/// Legacy wrapper object whose payload was stored as stringified JSON. -#[derive(Debug, Deserialize)] -struct LegacyEnvelope { - content: String, -} - -/// Legacy top-level collection container read from an envelope payload. -#[derive(Debug, Deserialize)] -struct LegacyCollections { - c: Option>, -} - -/// Result of translating one legacy collection into a preset or a skip reason. -enum LegacyCollectionOutcome { - Preset { - /// Imported preset built from the legacy collection. - preset: NamedPreset, - /// Per-entry legacy notes collected while building the preset. - warnings: Vec, - }, - Skip { - /// Legacy collection name when one was present in the payload. - collection_name: Option, - /// Summary of why the collection could not produce a preset. - skip_message: String, - /// Per-entry legacy notes collected before the collection was skipped. - warnings: Vec, - }, -} - -/// Validate then serializes a preset snapshot into the native named-presets -/// JSON document. -pub fn serialize_named_presets(presets: Vec) -> Result { - // Runtime preset ids are intentionally ignored here because the file format - // stores only semantic preset data. - let presets = presets - .into_iter() - .map(|preset| NamedPreset { - name: preset.name, - filters: preset.filters, - search_values: preset.search_values, - }) - .collect::>(); - - serialize_document(presets) -} - -fn serialize_document(presets: Vec) -> Result { - presets.iter().try_for_each(validate_named_preset)?; - - let document = Document { - kind: DOCUMENT_KIND.to_owned(), - version: DOCUMENT_VERSION, - presets, - }; - - serde_json::to_string_pretty(&document) - .map_err(|err| format!("invalid native preset document: {err}")) -} - -/// Parses a native preset document or a supported legacy export. -/// -/// Returned presets already have fresh runtime ids assigned so the UI can hand -/// them to the registry import path directly. -pub fn import_named_presets(text: &str) -> Result { - let value = parse_root_value(text)?; - let (format, presets, warnings) = match value { - Value::Object(root) => { - let presets = parse_document_from_value(root)?; - (ImportFormat::Version1, presets, Vec::new()) - } - Value::Array(items) => { - let (presets, warnings) = parse_legacy_from_value(items)?; - (ImportFormat::Legacy, presets, warnings) - } - _ => return Err("preset document root must be an object or array".to_owned()), - }; - - Ok(ImportReport { - format, - presets: presets.into_iter().map(Preset::from).collect(), - warnings, - }) -} - -fn parse_root_value(text: &str) -> Result { - let trimmed = text.trim_start(); - serde_json::from_str(text).map_err(|err| { - // The legacy export uses a top-level array while the native format uses - // an object, so the first non-whitespace token is enough to classify - // syntax failures for the user-facing error. - if trimmed.starts_with('[') { - format!("invalid legacy preset export: {err}") - } else { - format!("invalid native preset document: {err}") - } - }) -} - -fn parse_document_from_value( - root: serde_json::Map, -) -> Result, String> { - let kind = root - .get("kind") - .and_then(Value::as_str) - .ok_or_else(|| "unsupported preset document kind: ".to_owned())?; - if kind != DOCUMENT_KIND { - return Err(format!("unsupported preset document kind: {kind}")); - } - - let version = root - .get("version") - .and_then(Value::as_u64) - .ok_or_else(|| "unsupported preset document version: 0".to_owned())?; - let version = u8::try_from(version).unwrap_or(u8::MAX); - if version != DOCUMENT_VERSION { - return Err(format!("unsupported preset document version: {version}")); - } - - let document: Document = serde_json::from_value(Value::Object(root)) - .map_err(|err| format!("invalid native preset document: {err}"))?; - document - .presets - .iter() - .try_for_each(validate_named_preset)?; - Ok(document.presets) -} - -fn validate_named_preset(preset: &NamedPreset) -> Result<(), String> { - if preset.name.trim().is_empty() { - return Err("preset name cannot be blank".to_owned()); - } - - for filter in &preset.filters { - match validate_filter(filter) { - ValidationEligibility::Eligible => {} - ValidationEligibility::Ineligible { reason } => { - return Err(format!( - "invalid filter in preset '{}': {reason}", - preset.name - )); - } - } - } - - for search_value in &preset.search_values { - match validate_search_value_filter(search_value) { - ValidationEligibility::Eligible => {} - ValidationEligibility::Ineligible { reason } => { - return Err(format!( - "invalid search value in preset '{}': {reason}", - preset.name - )); - } - } - } - - Ok(()) -} - -fn parse_legacy_from_value( - items: Vec, -) -> Result<(Vec, Vec), String> { - // Legacy export shape: - // [ - // { - // "content": "{\"c\":[{\"content\":\"{...collection json...}\"}]}" - // } - // ] - // - // Please refer to export/imports tests for legacy export samples. - let envelopes: Vec = serde_json::from_value(Value::Array(items)) - .map_err(|err| format!("invalid legacy preset export: {err}"))?; - let mut presets = Vec::new(); - let mut warnings = Vec::new(); - let mut found_collections = false; - - for envelope in envelopes { - let collections: LegacyCollections = match serde_json::from_str(&envelope.content) { - Ok(collections) => collections, - Err(_) => { - warnings.push(ImportWarning::LegacyCollectionSkipped { - collection_name: None, - reason: "invalid JSON".to_owned(), - }); - continue; - } - }; - let Some(collections) = collections.c else { - continue; - }; - found_collections = true; - - for collection in collections { - match parse_legacy_collection(&collection.content) { - Ok(LegacyCollectionOutcome::Preset { - preset, - warnings: local, - }) => { - let mut local = local; - presets.push(preset); - warnings.append(&mut local); - } - Ok(LegacyCollectionOutcome::Skip { - collection_name, - skip_message: reason, - warnings: local, - }) => { - warnings.extend(local); - warnings.push(ImportWarning::LegacyCollectionSkipped { - collection_name, - reason, - }); - } - Err(_) => warnings.push(ImportWarning::LegacyCollectionSkipped { - collection_name: None, - reason: "invalid JSON".to_owned(), - }), - } - } - } - - if !found_collections { - return Err("legacy preset export is missing collections".to_owned()); - } - - Ok((presets, warnings)) -} - -fn parse_legacy_collection(content: &str) -> Result { - let value: Value = serde_json::from_str(content)?; - let collection_name = value.get("n").and_then(Value::as_str).map(str::to_owned); - let Some(name) = collection_name.clone() else { - return Ok(LegacyCollectionOutcome::Skip { - collection_name: None, - skip_message: "missing name".to_owned(), - warnings: Vec::new(), - }); - }; - if name.trim().is_empty() || name == "-" { - return Ok(LegacyCollectionOutcome::Skip { - collection_name: Some(name), - skip_message: "missing name".to_owned(), - warnings: Vec::new(), - }); - } - - let entries = value - .get("e") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - let mut filters = Vec::new(); - let mut search_values = Vec::new(); - let mut warnings = Vec::new(); - let mut ignored_bookmarks = 0; - let mut invalid_filters = 0; - let mut invalid_charts = 0; - let mut unsupported_counts = std::collections::BTreeMap::::new(); - - // Legacy exports do not reliably distinguish preset exports from direct - // filter/chart exports, so any named collection with semantic entries is - // treated as an importable preset. - for entry in entries { - let Some(entry_map) = entry.as_object() else { - continue; - }; - let Some((entry_kind, payload)) = entry_map.iter().next() else { - continue; - }; - let Some(payload) = payload.as_str() else { - *unsupported_counts.entry(entry_kind.clone()).or_default() += 1; - continue; - }; - - match entry_kind.as_str() { - "filters" => match parse_legacy_filter(payload) { - Ok(filter) => filters.push(filter), - Err(_) => invalid_filters += 1, - }, - "charts" => match parse_legacy_chart(payload) { - Ok(search_value) => search_values.push(search_value), - Err(_) => invalid_charts += 1, - }, - "bookmark" => ignored_bookmarks += 1, - other => *unsupported_counts.entry(other.to_owned()).or_default() += 1, - } - } - - if ignored_bookmarks > 0 { - warnings.push(ImportWarning::LegacyEntryIgnored { - preset_name: name.clone(), - entry_kind: LegacyEntryKind::Bookmark, - count: ignored_bookmarks, - }); - } - if invalid_filters > 0 { - warnings.push(ImportWarning::LegacyEntryIgnored { - preset_name: name.clone(), - entry_kind: LegacyEntryKind::InvalidFilter, - count: invalid_filters, - }); - } - if invalid_charts > 0 { - warnings.push(ImportWarning::LegacyEntryIgnored { - preset_name: name.clone(), - entry_kind: LegacyEntryKind::InvalidChart, - count: invalid_charts, - }); - } - for (kind, count) in unsupported_counts { - warnings.push(ImportWarning::LegacyEntryIgnored { - preset_name: name.clone(), - entry_kind: LegacyEntryKind::Unsupported(kind), - count, - }); - } - - if filters.is_empty() && search_values.is_empty() { - return Ok(LegacyCollectionOutcome::Skip { - collection_name: Some(name), - skip_message: "no filters or charts to import".to_owned(), - warnings, - }); - } - - Ok(LegacyCollectionOutcome::Preset { - preset: NamedPreset { - name, - filters, - search_values, - }, - warnings, - }) -} - -fn parse_legacy_filter(payload: &str) -> Result { - let value: Value = serde_json::from_str(payload)?; - let filter = value - .get("filter") - .and_then(Value::as_object) - .ok_or_else(|| serde_json::Error::io(std::io::Error::other("missing filter")))?; - let text = filter - .get("filter") - .and_then(Value::as_str) - .ok_or_else(|| serde_json::Error::io(std::io::Error::other("missing filter text")))?; - let flags = filter - .get("flags") - .and_then(Value::as_object) - .ok_or_else(|| serde_json::Error::io(std::io::Error::other("missing flags")))?; - let regex = flags.get("reg").and_then(Value::as_bool).unwrap_or(false); - let word = flags.get("word").and_then(Value::as_bool).unwrap_or(false); - let cases = flags.get("cases").and_then(Value::as_bool).unwrap_or(false); - - let filter = SearchFilter::plain(text) - .regex(regex) - .word(word) - .ignore_case(!cases); - if !validate_filter(&filter).is_eligible() { - return Err(serde_json::Error::io(std::io::Error::other( - "invalid legacy filter", - ))); - } - - Ok(filter) -} - -fn parse_legacy_chart(payload: &str) -> Result { - let value: Value = serde_json::from_str(payload)?; - let text = value - .get("filter") - .and_then(Value::as_str) - .ok_or_else(|| serde_json::Error::io(std::io::Error::other("missing chart filter")))?; - // Legacy chart entries are really regex-backed search values, not literal - // filters, so they map to the search-value side of the native model. - let filter = SearchFilter::plain(text).regex(true).ignore_case(true); - if !validate_search_value_filter(&filter).is_eligible() { - return Err(serde_json::Error::io(std::io::Error::other( - "invalid legacy chart", - ))); - } - - Ok(filter) -} - -impl From for Preset { - fn from(value: NamedPreset) -> Self { - Self { - // Import always creates fresh runtime ids. Name collision handling is - // deferred to the UI registry import path. - id: Uuid::new_v4(), - name: value.name, - filters: value.filters, - search_values: value.search_values, - } - } -} - -impl fmt::Display for LegacyEntryKind { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Bookmark => f.write_str("bookmark"), - Self::InvalidFilter => f.write_str("invalid filter"), - Self::InvalidChart => f.write_str("invalid chart"), - Self::Unsupported(kind) => f.write_str(kind), - } - } -} - -impl fmt::Display for ImportWarning { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::LegacyCollectionSkipped { - collection_name, - reason, - } => match collection_name { - Some(name) => write!(f, "Skipped legacy collection '{name}': {reason}."), - None => write!(f, "Skipped a legacy collection: {reason}."), - }, - Self::LegacyEntryIgnored { - preset_name, - entry_kind, - count, - } => { - let noun = if *count == 1 { "entry" } else { "entries" }; - write!( - f, - "Ignored {count} {entry_kind} {noun} while importing preset '{preset_name}'." - ) - } - } - } -} - -#[cfg(test)] -mod tests { - use std::{fs, path::PathBuf}; - - use rustc_hash::FxHashSet; - - use super::*; - - fn plain(value: &str) -> SearchFilter { - SearchFilter::plain(value).ignore_case(true) - } - - fn regex(value: &str) -> SearchFilter { - SearchFilter::plain(value).regex(true).ignore_case(true) - } - - fn preset(name: &str, filters: Vec, search_values: Vec) -> Preset { - Preset { - id: Uuid::new_v4(), - name: name.to_owned(), - filters, - search_values, - } - } - - fn semantic_snapshot( - presets: &[Preset], - ) -> Vec<(String, Vec, Vec)> { - presets - .iter() - .map(|preset| { - ( - preset.name.clone(), - preset.filters.clone(), - preset.search_values.clone(), - ) - }) - .collect() - } - - fn fixture_text(name: &str) -> String { - let path = fixture_dir().join(name); - fs::read_to_string(path).expect("fixture should be readable") - } - - fn fixture_dir() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata/presets_io") - } - - fn parse_document(text: &str) -> Result, String> { - let Value::Object(root) = parse_root_value(text)? else { - return Err("preset document root must be an object or array".to_owned()); - }; - - parse_document_from_value(root) - } - - #[test] - fn native_round_trip() { - let document = vec![ - NamedPreset { - name: "Errors".to_owned(), - filters: vec![plain("error"), plain("warn"), plain("error")], - search_values: vec![regex("duration=(\\d+)")], - }, - NamedPreset { - name: "Values".to_owned(), - filters: vec![], - search_values: vec![regex("latency=(\\d+)"), regex("latency=(\\d+)")], - }, - ]; - let expected = document.clone(); - - let json = serialize_document(document).unwrap(); - let parsed = parse_document(&json).unwrap(); - - assert_eq!(parsed, expected); - } - - #[test] - fn native_rejects_kind() { - parse_document(r#"{"kind":"wrong","version":1,"presets":[]}"#).unwrap_err(); - } - - #[test] - fn native_rejects_version() { - parse_document(r#"{"kind":"chipmunk_named_presets","version":2,"presets":[]}"#) - .unwrap_err(); - } - - #[test] - fn native_rejects_blank_name() { - parse_document( - r#"{"kind":"chipmunk_named_presets","version":1,"presets":[{"name":" ","filters":[],"search_values":[]}]}"#, - ) - .unwrap_err(); - } - - #[test] - fn native_rejects_invalid_filter() { - parse_document( - r#"{"kind":"chipmunk_named_presets","version":1,"presets":[{"name":"Broken","filters":[{"value":"(","is_regex":true,"ignore_case":true,"is_word":false}],"search_values":[]}]}"#, - ) - .unwrap_err(); - } - - #[test] - fn native_rejects_invalid_search_value() { - parse_document( - r#"{"kind":"chipmunk_named_presets","version":1,"presets":[{"name":"Broken","filters":[],"search_values":[{"value":"cpu=(.+)","is_regex":true,"ignore_case":true,"is_word":false}]}]}"#, - ) - .unwrap_err(); - } - - #[test] - fn export_never_uses_legacy_shape() { - let presets = vec![preset( - "Errors", - vec![plain("error")], - vec![regex("duration=(\\d+)")], - )]; - - let json = serialize_named_presets(presets).unwrap(); - - assert!(json.contains("\"kind\": \"chipmunk_named_presets\"")); - assert!(json.contains("\"search_values\"")); - assert!(json.contains("\"is_regex\"")); - assert!(!json.contains("\"searchValues\"")); - assert!(!json.contains("\"content\"")); - assert!(!json.contains("\"uuid\"")); - } - - #[test] - fn serialize_rejects_invalid_search_value() { - let document = vec![NamedPreset { - name: "Broken".to_owned(), - filters: vec![], - search_values: vec![regex("cpu=(.+)")], - }]; - - serialize_document(document).unwrap_err(); - } - - #[test] - fn import_new_document_keeps_duplicates() { - let json = r#" - { - "kind": "chipmunk_named_presets", - "version": 1, - "presets": [ - { - "name": "Errors", - "filters": [ - { "value": "error", "is_regex": false, "ignore_case": true, "is_word": false }, - { "value": "error", "is_regex": false, "ignore_case": true, "is_word": false } - ], - "search_values": [ - { "value": "duration=(\\d+)", "is_regex": true, "ignore_case": true, "is_word": false }, - { "value": "duration=(\\d+)", "is_regex": true, "ignore_case": true, "is_word": false } - ] - } - ] - } - "#; - - let report = import_named_presets(json).unwrap(); - let preset = &report.presets[0]; - - assert_eq!(report.format, ImportFormat::Version1); - assert_eq!(preset.filters, vec![plain("error"), plain("error")]); - assert_eq!( - preset.search_values, - vec![regex("duration=(\\d+)"), regex("duration=(\\d+)")] - ); - } - - #[test] - fn import_new_document_preserves_duplicate_names() { - let json = r#" - { - "kind": "chipmunk_named_presets", - "version": 1, - "presets": [ - { "name": "Same", "filters": [], "search_values": [] }, - { "name": "Same", "filters": [], "search_values": [] } - ] - } - "#; - - let report = import_named_presets(json).unwrap(); - - assert_eq!(report.presets.len(), 2); - assert_eq!(report.presets[0].name, "Same"); - assert_eq!(report.presets[1].name, "Same"); - let ids = report - .presets - .iter() - .map(|preset| preset.id) - .collect::>(); - assert_eq!(ids.len(), 2); - } - - #[test] - fn import_rejects_object_without_native_kind() { - import_named_presets(r#"{"presets":[]}"#).unwrap_err(); - } - - #[test] - fn imports_one_legacy_preset() { - let report = import_named_presets(&fixture_text("one_preset_1.json")).unwrap(); - - assert_eq!(report.format, ImportFormat::Legacy); - assert_eq!(report.presets.len(), 1); - let preset = &report.presets[0]; - assert_eq!(preset.name, "journalctl"); - assert_eq!(preset.filters.len(), 4); - assert!(preset.search_values.is_empty()); - } - - #[test] - fn imports_multiple_legacy_presets() { - let report = import_named_presets(&fixture_text("multiple_presets_1.json")).unwrap(); - - assert_eq!(report.format, ImportFormat::Legacy); - assert_eq!(report.presets.len(), 2); - assert_eq!(report.presets[0].name, "journalctl"); - assert_eq!(report.presets[1].name, "files *..dlt"); - } - - #[test] - fn legacy_chart_only_becomes_search_value() { - let report = import_named_presets(&fixture_text("preset_chart_only.json")).unwrap(); - - let preset = &report.presets[0]; - assert!(preset.filters.is_empty()); - assert_eq!(preset.search_values, vec![regex("(\\d)")]); - } - - #[test] - fn legacy_filter_only_stays_filter_only() { - let report = import_named_presets(&fixture_text("presets_filter_only.json")).unwrap(); - - assert_eq!(report.presets.len(), 2); - assert!( - report - .presets - .iter() - .all(|preset| preset.search_values.is_empty()) - ); - } - - #[test] - fn legacy_filters_export_is_imported_as_preset() { - let report = import_named_presets(&fixture_text("filters_1.json")).unwrap(); - - assert_eq!(report.presets.len(), 1); - let preset = &report.presets[0]; - assert_eq!(preset.name, "ping www.google.com"); - assert_eq!(preset.filters.len(), 6); - assert_eq!(preset.search_values.len(), 1); - assert!(report.warnings.iter().any(|warning| matches!( - warning, - ImportWarning::LegacyEntryIgnored { - preset_name, - entry_kind: LegacyEntryKind::Bookmark, - count: 3, - } if preset_name == "ping www.google.com" - ))); - } - - #[test] - fn legacy_duplicate_names_are_preserved() { - let report = import_named_presets(&fixture_text("presets_same_name.json")).unwrap(); - - assert_eq!(report.presets.len(), 3); - assert_eq!(report.presets[0].name, "SameName"); - assert_eq!(report.presets[1].name, "SameName"); - assert_eq!(report.presets[2].name, "journalctl"); - } - - #[test] - fn legacy_preserves_filter_flags() { - let report = import_named_presets(&fixture_text("filters_1.json")).unwrap(); - - let preset = &report.presets[0]; - assert!(!preset.filters[0].is_regex()); - assert!(preset.filters[1].is_regex()); - assert!(preset.filters.iter().all(|filter| filter.is_ignore_case())); - } - - #[test] - fn export_then_import_matches_semantics() { - let source = vec![ - preset( - "Errors", - vec![plain("error"), plain("warn"), plain("error")], - vec![ - regex("duration=(\\d+)"), - regex("latency=(\\d+)"), - regex("duration=(\\d+)"), - ], - ), - preset("Charts", vec![], vec![regex("temp=(\\d+)")]), - ]; - - let json = serialize_named_presets(source.clone()).unwrap(); - let report = import_named_presets(&json).unwrap(); - - assert_eq!( - semantic_snapshot(&report.presets), - semantic_snapshot(&source) - ); - } - - #[test] - fn legacy_fixture_alias_imports_same_semantics() { - let first = import_named_presets(&fixture_text("filters_1.json")).unwrap(); - let second = - import_named_presets(&fixture_text("same_as_filters_1_as_preset.json")).unwrap(); - - assert_eq!( - semantic_snapshot(&first.presets), - semantic_snapshot(&second.presets) - ); - } - - #[test] - fn legacy_skips_invalid_chart_entry() { - let chart_entry = serde_json::json!({ - "filter": "cpu=(.+)", - "uuid": "chart", - "active": true, - "color": "#ffffff", - "type": "Linear", - "widths": { "line": 1, "point": 0 } - }); - let collection = serde_json::json!({ - "n": "BrokenCharts", - "e": [ - { - "charts": chart_entry.to_string() - } - ] - }); - let outer = serde_json::json!([ - { - "uuid": "outer", - "content": serde_json::json!({ - "c": [ - { - "uuid": "col", - "content": collection.to_string() - } - ] - }) - .to_string() - } - ]); - - let report = import_named_presets(&outer.to_string()).unwrap(); - - assert!(report.presets.is_empty()); - assert!(report.warnings.iter().any(|warning| matches!( - warning, - ImportWarning::LegacyEntryIgnored { - preset_name, - entry_kind: LegacyEntryKind::InvalidChart, - count: 1, - } if preset_name == "BrokenCharts" - ))); - assert!(report.warnings.iter().any(|warning| matches!( - warning, - ImportWarning::LegacyCollectionSkipped { - collection_name: Some(name), - .. - } if name == "BrokenCharts" - ))); - } -} diff --git a/crates/app/src/host/service/presets_io/legacy.rs b/crates/app/src/host/service/presets_io/legacy.rs new file mode 100644 index 0000000000..e28fa15214 --- /dev/null +++ b/crates/app/src/host/service/presets_io/legacy.rs @@ -0,0 +1,769 @@ +//! Legacy preset import for Chipmunk V3 TypeScript exports. +//! +//! Legacy documents use nested stringified JSON envelopes. Decoded collections +//! become presets; filter entries map to filters, and chart entries map to +//! search values. +//! +//! Legacy export shape. Both `content` fields are stringified JSON. +//! ```json +//! [ +//! { "content": "{\"c\":[{\"content\":\"{...collection json...}\"}]}" } +//! ] +//! ``` +//! Decoded collection payload: +//! ```json +//! { +//! "n": "Errors", +//! "e": [ +//! { "filters": "{...filter json...}" }, +//! { "charts": "{...chart json...}" } +//! ] +//! } +//! ``` + +use std::{collections::BTreeMap, io::Error as IoError}; + +use egui::Color32; +use processor::search::filter::SearchFilter; +use serde::Deserialize; +use serde_json::{Error as JsonError, Value}; +use uuid::Uuid; + +use crate::{ + common::validation::{validate_filter, validate_search_value_filter}, + host::{ + common::colors::{self, ColorPair}, + ui::registry::presets::{Preset, PresetFilterEntry, PresetSearchValueEntry}, + }, +}; + +use super::{ImportWarning, LegacyEntryKind}; + +/// Legacy wrapper object whose payload was stored as stringified JSON. +#[derive(Debug, Deserialize)] +struct LegacyEnvelope { + /// Stringified JSON payload for either a collection list or collection body. + content: String, +} + +/// Legacy top-level collection container read from an envelope payload. +#[derive(Debug, Deserialize)] +struct LegacyCollections { + /// Legacy collection envelopes under the short `c` key. + c: Option>, +} + +/// Result of translating one legacy collection into a preset or a skip reason. +enum LegacyCollectionOutcome { + Preset { + /// Imported preset built from the legacy collection. + preset: Preset, + /// Per-entry legacy notes collected while building the preset. + warnings: Vec, + }, + Skip { + /// Legacy collection name when one was present in the payload. + collection_name: Option, + /// Summary of why the collection could not produce a preset. + skip_message: String, + /// Per-entry legacy notes collected before the collection was skipped. + warnings: Vec, + }, +} + +/// Parses the legacy top-level export array into runtime presets and warnings. +pub fn parse_legacy_from_value( + items: Vec, +) -> Result<(Vec, Vec), String> { + // Legacy export shape: + // [ + // { + // "content": "{\"c\":[{\"content\":\"{...collection json...}\"}]}" + // } + // ] + // + // Please refer to export/imports tests for legacy export samples. + let root = Value::Array(items); + let envelopes: Vec = serde_json::from_value(root) + .map_err(|err| format!("invalid legacy preset export: {err}"))?; + let mut presets = Vec::new(); + let mut warnings = Vec::new(); + let mut found_collections = false; + + for envelope in envelopes { + let collections: LegacyCollections = match serde_json::from_str(&envelope.content) { + Ok(collections) => collections, + Err(_) => { + warnings.push(ImportWarning::LegacyCollectionSkipped { + collection_name: None, + reason: "invalid JSON".to_owned(), + }); + continue; + } + }; + let Some(collections) = collections.c else { + continue; + }; + found_collections = true; + + for collection in collections { + match parse_legacy_collection(&collection.content) { + Ok(LegacyCollectionOutcome::Preset { + preset, + warnings: local, + }) => { + let mut local = local; + presets.push(preset); + warnings.append(&mut local); + } + Ok(LegacyCollectionOutcome::Skip { + collection_name, + skip_message: reason, + warnings: local, + }) => { + warnings.extend(local); + warnings.push(ImportWarning::LegacyCollectionSkipped { + collection_name, + reason, + }); + } + Err(_) => { + let warning = ImportWarning::LegacyCollectionSkipped { + collection_name: None, + reason: "invalid JSON".to_owned(), + }; + warnings.push(warning); + } + } + } + } + + if !found_collections { + return Err("legacy preset export is missing collections".to_owned()); + } + + Ok((presets, warnings)) +} + +/// Converts one decoded legacy collection body into a named preset. +fn parse_legacy_collection(content: &str) -> Result { + let value: Value = serde_json::from_str(content)?; + let collection_name = value.get("n").and_then(Value::as_str).map(str::to_owned); + let Some(name) = collection_name.clone() else { + let outcome = LegacyCollectionOutcome::Skip { + collection_name: None, + skip_message: "missing name".to_owned(), + warnings: Vec::new(), + }; + return Ok(outcome); + }; + if name.trim().is_empty() || name == "-" { + let outcome = LegacyCollectionOutcome::Skip { + collection_name: Some(name), + skip_message: "missing name".to_owned(), + warnings: Vec::new(), + }; + return Ok(outcome); + } + + let entries = value + .get("e") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let mut filters = Vec::new(); + let mut search_values = Vec::new(); + let mut warnings = Vec::new(); + let mut ignored_bookmarks = 0; + let mut invalid_filters = 0; + let mut invalid_charts = 0; + let mut unsupported_counts = BTreeMap::::new(); + + // Legacy exports do not reliably distinguish preset exports from direct + // filter/chart exports, so any named collection with filter/chart entries + // is treated as an importable preset. + for entry in entries { + let Some(entry_map) = entry.as_object() else { + continue; + }; + let Some((entry_kind, payload)) = entry_map.iter().next() else { + continue; + }; + let Some(payload) = payload.as_str() else { + *unsupported_counts.entry(entry_kind.clone()).or_default() += 1; + continue; + }; + + match entry_kind.as_str() { + "filters" => match parse_legacy_filter(payload, filters.len()) { + Ok(filter) => filters.push(filter), + Err(_) => invalid_filters += 1, + }, + "charts" => match parse_legacy_chart(payload, search_values.len()) { + Ok(search_value) => search_values.push(search_value), + Err(_) => invalid_charts += 1, + }, + "bookmark" => ignored_bookmarks += 1, + other => *unsupported_counts.entry(other.to_owned()).or_default() += 1, + } + } + + if ignored_bookmarks > 0 { + warnings.push(ImportWarning::LegacyEntryIgnored { + preset_name: name.clone(), + entry_kind: LegacyEntryKind::Bookmark, + count: ignored_bookmarks, + }); + } + if invalid_filters > 0 { + warnings.push(ImportWarning::LegacyEntryIgnored { + preset_name: name.clone(), + entry_kind: LegacyEntryKind::InvalidFilter, + count: invalid_filters, + }); + } + if invalid_charts > 0 { + warnings.push(ImportWarning::LegacyEntryIgnored { + preset_name: name.clone(), + entry_kind: LegacyEntryKind::InvalidChart, + count: invalid_charts, + }); + } + for (kind, count) in unsupported_counts { + warnings.push(ImportWarning::LegacyEntryIgnored { + preset_name: name.clone(), + entry_kind: LegacyEntryKind::Unsupported(kind), + count, + }); + } + + if filters.is_empty() && search_values.is_empty() { + let outcome = LegacyCollectionOutcome::Skip { + collection_name: Some(name), + skip_message: "no filters or charts to import".to_owned(), + warnings, + }; + return Ok(outcome); + } + + let preset = Preset { + id: Uuid::new_v4(), + name, + filters, + search_values, + }; + let outcome = LegacyCollectionOutcome::Preset { preset, warnings }; + + Ok(outcome) +} + +/// Converts one stringified legacy filter entry into a native filter row snapshot. +fn parse_legacy_filter(payload: &str, index: usize) -> Result { + let value: Value = serde_json::from_str(payload)?; + let filter = value + .get("filter") + .and_then(Value::as_object) + .ok_or_else(|| JsonError::io(IoError::other("missing filter")))?; + let text = filter + .get("filter") + .and_then(Value::as_str) + .ok_or_else(|| JsonError::io(IoError::other("missing filter text")))?; + let flags = filter + .get("flags") + .and_then(Value::as_object) + .ok_or_else(|| JsonError::io(IoError::other("missing flags")))?; + let regex = flags.get("reg").and_then(Value::as_bool).unwrap_or(false); + let word = flags.get("word").and_then(Value::as_bool).unwrap_or(false); + let cases = flags.get("cases").and_then(Value::as_bool).unwrap_or(false); + + let filter = SearchFilter::plain(text) + .regex(regex) + .word(word) + .ignore_case(!cases); + if !validate_filter(&filter).is_eligible() { + return Err(JsonError::io(IoError::other("invalid legacy filter"))); + } + + let enabled = parse_legacy_enabled(&value); + let colors = parse_legacy_filter_colors(&value, index); + + let entry = PresetFilterEntry::new(filter, enabled, colors); + + Ok(entry) +} + +/// Converts one stringified legacy chart entry into a native search-value row snapshot. +fn parse_legacy_chart(payload: &str, index: usize) -> Result { + let value: Value = serde_json::from_str(payload)?; + let text = value + .get("filter") + .and_then(Value::as_str) + .ok_or_else(|| JsonError::io(IoError::other("missing chart filter")))?; + // Legacy chart entries are really regex-backed search values, not literal + // filters, so they map to the search-value side of the native model. + let filter = SearchFilter::plain(text).regex(true).ignore_case(true); + if !validate_search_value_filter(&filter).is_eligible() { + return Err(JsonError::io(IoError::other("invalid legacy chart"))); + } + + let enabled = parse_legacy_enabled(&value); + let color = parse_legacy_chart_color(&value, index); + + let entry = PresetSearchValueEntry::new(filter, enabled, color); + + Ok(entry) +} + +/// Reads a legacy `active` flag, defaulting missing or invalid values to enabled. +fn parse_legacy_enabled(value: &Value) -> bool { + match value.get("active") { + Some(value) => value.as_bool().unwrap_or_else(|| { + log::info!("Invalid legacy preset active metadata; using default enabled state."); + true + }), + None => { + log::info!("Missing legacy preset active metadata; using default enabled state."); + true + } + } +} + +/// Reads legacy filter colors, defaulting the whole pair when either color is unusable. +fn parse_legacy_filter_colors(value: &Value, index: usize) -> ColorPair { + let default = default_filter_colors(index); + let Some(colors) = value.get("colors") else { + log::info!("Missing legacy preset filter color metadata; using default filter colors."); + return default; + }; + let Some(colors) = colors.as_object() else { + log::info!("Invalid legacy preset filter color metadata; using default filter colors."); + return default; + }; + + let Some(fg) = colors.get("color") else { + log::info!("Missing legacy preset filter color metadata; using default filter colors."); + return default; + }; + let Some(fg) = fg.as_str() else { + log::info!("Invalid legacy preset filter color metadata; using default filter colors."); + return default; + }; + let Some(bg) = colors.get("background") else { + log::info!("Missing legacy preset filter color metadata; using default filter colors."); + return default; + }; + let Some(bg) = bg.as_str() else { + log::info!("Invalid legacy preset filter color metadata; using default filter colors."); + return default; + }; + + let Some(fg) = parse_legacy_hex_color(fg) else { + log::info!("Invalid legacy preset filter color metadata; using default filter colors."); + return default; + }; + let Some(bg) = parse_legacy_hex_color(bg) else { + log::info!("Invalid legacy preset filter color metadata; using default filter colors."); + return default; + }; + + ColorPair::new(fg, bg) +} + +/// Reads a legacy chart color, defaulting to the native search-value palette. +fn parse_legacy_chart_color(value: &Value, index: usize) -> Color32 { + let default = colors::search_value_color(index); + let Some(color) = value.get("color") else { + log::info!("Missing legacy preset chart color metadata; using default chart color."); + return default; + }; + let Some(color) = color.as_str() else { + log::info!("Invalid legacy preset chart color metadata; using default chart color."); + return default; + }; + + parse_legacy_hex_color(color).unwrap_or_else(|| { + log::info!("Invalid legacy preset chart color metadata; using default chart color."); + default + }) +} + +/// Returns the native default filter color pair for a legacy row index. +fn default_filter_colors(index: usize) -> ColorPair { + colors::FILTER_HIGHLIGHT_COLORS[index % colors::FILTER_HIGHLIGHT_COLORS.len()].clone() +} + +/// Parses the only legacy color format this importer supports: `#RRGGBB`. +fn parse_legacy_hex_color(value: &str) -> Option { + let bytes = value.as_bytes(); + if bytes.len() != 7 || bytes[0] != b'#' { + return None; + } + + let red = parse_hex_byte(&bytes[1..3])?; + let green = parse_hex_byte(&bytes[3..5])?; + let blue = parse_hex_byte(&bytes[5..7])?; + + Some(Color32::from_rgb(red, green, blue)) +} + +/// Parses two ASCII hex digits into one byte. +fn parse_hex_byte(pair: &[u8]) -> Option { + let [high, low] = pair else { + return None; + }; + let high = hex_value(*high)?; + let low = hex_value(*low)?; + + Some((high << 4) | low) +} + +/// Converts one ASCII hex digit into its numeric value. +fn hex_value(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use std::{fs, path::PathBuf}; + + use egui::Color32; + use processor::search::filter::SearchFilter; + + use crate::host::{ + common::colors::{self, ColorPair}, + message::ImportFormat, + service::presets_io::{ImportWarning, LegacyEntryKind, import_named_presets}, + ui::registry::presets::Preset, + }; + + fn regex(value: &str) -> SearchFilter { + SearchFilter::plain(value).regex(true).ignore_case(true) + } + + fn search_value_definitions(preset: &Preset) -> Vec { + preset + .search_values + .iter() + .map(|entry| entry.filter.clone()) + .collect() + } + + fn fixture_text(name: &str) -> String { + let path = fixture_dir().join(name); + fs::read_to_string(path).expect("fixture should be readable") + } + + fn fixture_dir() -> PathBuf { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + manifest_dir.join("testdata/presets_io") + } + + fn legacy_document(collection_name: &str, entries: Vec) -> String { + let collection = serde_json::json!({ + "n": collection_name, + "e": entries, + }); + let collection_envelope = serde_json::json!({ + "c": [ + { + "uuid": "col", + "content": collection.to_string(), + } + ] + }); + + serde_json::json!([ + { + "uuid": "outer", + "content": collection_envelope.to_string(), + } + ]) + .to_string() + } + + #[test] + fn imports_one_legacy_preset() { + let report = import_named_presets(&fixture_text("one_preset_1.json")).unwrap(); + + assert_eq!(report.format, ImportFormat::Legacy); + assert_eq!(report.presets.len(), 1); + let preset = &report.presets[0]; + assert_eq!(preset.name, "journalctl"); + assert_eq!(preset.filters.len(), 4); + assert!(preset.search_values.is_empty()); + } + + #[test] + fn imports_multiple_legacy_presets() { + let report = import_named_presets(&fixture_text("multiple_presets_1.json")).unwrap(); + + assert_eq!(report.format, ImportFormat::Legacy); + assert_eq!(report.presets.len(), 2); + assert_eq!(report.presets[0].name, "journalctl"); + assert_eq!(report.presets[1].name, "files *..dlt"); + } + + #[test] + fn legacy_chart_only_becomes_search_value() { + let report = import_named_presets(&fixture_text("preset_chart_only.json")).unwrap(); + + let preset = &report.presets[0]; + assert!(preset.filters.is_empty()); + assert_eq!(search_value_definitions(preset), vec![regex("(\\d)")]); + } + + #[test] + fn legacy_filter_only_stays_filter_only() { + let report = import_named_presets(&fixture_text("presets_filter_only.json")).unwrap(); + + assert_eq!(report.presets.len(), 2); + assert!( + report + .presets + .iter() + .all(|preset| preset.search_values.is_empty()) + ); + } + + #[test] + fn legacy_filters_export_is_imported_as_preset() { + let report = import_named_presets(&fixture_text("filters_1.json")).unwrap(); + + assert_eq!(report.presets.len(), 1); + let preset = &report.presets[0]; + assert_eq!(preset.name, "ping www.google.com"); + assert_eq!(preset.filters.len(), 6); + assert_eq!(preset.search_values.len(), 1); + assert!(report.warnings.iter().any(|warning| matches!( + warning, + ImportWarning::LegacyEntryIgnored { + preset_name, + entry_kind: LegacyEntryKind::Bookmark, + count: 3, + } if preset_name == "ping www.google.com" + ))); + } + + #[test] + fn legacy_duplicate_names_are_preserved() { + let report = import_named_presets(&fixture_text("presets_same_name.json")).unwrap(); + + assert_eq!(report.presets.len(), 3); + assert_eq!(report.presets[0].name, "SameName"); + assert_eq!(report.presets[1].name, "SameName"); + assert_eq!(report.presets[2].name, "journalctl"); + } + + #[test] + fn legacy_preserves_filter_flags() { + let report = import_named_presets(&fixture_text("filters_1.json")).unwrap(); + + let preset = &report.presets[0]; + let filters = preset + .filters + .iter() + .map(|entry| &entry.filter) + .collect::>(); + assert!(!filters[0].is_regex()); + assert!(filters[1].is_regex()); + assert!(filters.iter().all(|filter| filter.is_ignore_case())); + } + + #[test] + fn legacy_preserves_fixture_state_and_colors() { + let report = import_named_presets(&fixture_text("one_preset_1.json")).unwrap(); + let preset = &report.presets[0]; + + assert!(preset.filters.iter().all(|entry| entry.enabled)); + assert_eq!( + preset.filters[1].colors, + ColorPair::new(Color32::WHITE, Color32::from_rgb(0xe6, 0x67, 0x67)) + ); + assert_eq!( + preset.filters[2].colors, + ColorPair::new(Color32::BLACK, Color32::from_rgb(0x55, 0xef, 0xc4)) + ); + + let report = import_named_presets(&fixture_text("preset_chart_only.json")).unwrap(); + let preset = &report.presets[0]; + + assert!(preset.search_values[0].enabled); + assert_eq!( + preset.search_values[0].color, + Color32::from_rgb(0xe4, 0xe1, 0x5b) + ); + } + + #[test] + fn legacy_preserves_disabled_state() { + let filter_entry = serde_json::json!({ + "filter": { + "filter": "error", + "flags": { "cases": false, "word": false, "reg": false } + }, + "active": false, + "colors": { "color": "#010203", "background": "#040506" } + }); + let chart_entry = serde_json::json!({ + "filter": "duration=(\\d+)", + "active": false, + "color": "#070809" + }); + let json = legacy_document( + "Stateful", + vec![ + serde_json::json!({ "filters": filter_entry.to_string() }), + serde_json::json!({ "charts": chart_entry.to_string() }), + ], + ); + + let report = import_named_presets(&json).unwrap(); + let preset = &report.presets[0]; + + assert!(report.warnings.is_empty()); + assert!(!preset.filters[0].enabled); + assert_eq!( + preset.filters[0].colors, + ColorPair::new(Color32::from_rgb(1, 2, 3), Color32::from_rgb(4, 5, 6)) + ); + assert!(!preset.search_values[0].enabled); + assert_eq!(preset.search_values[0].color, Color32::from_rgb(7, 8, 9)); + } + + #[test] + fn legacy_defaults_missing_or_invalid_metadata() { + let missing_filter_metadata = serde_json::json!({ + "filter": { + "filter": "first", + "flags": { "cases": false, "word": false, "reg": false } + } + }); + let invalid_filter_metadata = serde_json::json!({ + "filter": { + "filter": "second", + "flags": { "cases": false, "word": false, "reg": false } + }, + "active": "yes", + "colors": { "color": "#zzzzzz", "background": "#010203" } + }); + let missing_chart_metadata = serde_json::json!({ + "filter": "first=(\\d+)" + }); + let invalid_chart_metadata = serde_json::json!({ + "filter": "second=(\\d+)", + "active": "no", + "color": "red" + }); + let json = legacy_document( + "Defaults", + vec![ + serde_json::json!({ "filters": missing_filter_metadata.to_string() }), + serde_json::json!({ "filters": invalid_filter_metadata.to_string() }), + serde_json::json!({ "charts": missing_chart_metadata.to_string() }), + serde_json::json!({ "charts": invalid_chart_metadata.to_string() }), + ], + ); + + let report = import_named_presets(&json).unwrap(); + let preset = &report.presets[0]; + + assert!(report.warnings.is_empty()); + assert!(preset.filters.iter().all(|entry| entry.enabled)); + assert_eq!(preset.filters[0].colors, colors::FILTER_HIGHLIGHT_COLORS[0]); + assert_eq!(preset.filters[1].colors, colors::FILTER_HIGHLIGHT_COLORS[1]); + assert!(preset.search_values.iter().all(|entry| entry.enabled)); + assert_eq!(preset.search_values[0].color, colors::search_value_color(0)); + assert_eq!(preset.search_values[1].color, colors::search_value_color(1)); + } + + #[test] + fn legacy_fixture_alias_imports_same_definitions() { + let first = import_named_presets(&fixture_text("filters_1.json")).unwrap(); + let second = + import_named_presets(&fixture_text("same_as_filters_1_as_preset.json")).unwrap(); + + assert_eq!( + preset_snapshot(&first.presets), + preset_snapshot(&second.presets) + ); + } + + #[test] + fn legacy_skips_invalid_chart_entry() { + let chart_entry = serde_json::json!({ + "filter": "cpu=(.+)", + "uuid": "chart", + "active": true, + "color": "#ffffff", + "type": "Linear", + "widths": { "line": 1, "point": 0 } + }); + let collection = serde_json::json!({ + "n": "BrokenCharts", + "e": [ + { + "charts": chart_entry.to_string() + } + ] + }); + let collection_envelope = serde_json::json!({ + "c": [ + { + "uuid": "col", + "content": collection.to_string() + } + ] + }); + let outer = serde_json::json!([ + { + "uuid": "outer", + "content": collection_envelope.to_string() + } + ]); + + let report = import_named_presets(&outer.to_string()).unwrap(); + + assert!(report.presets.is_empty()); + assert!(report.warnings.iter().any(|warning| matches!( + warning, + ImportWarning::LegacyEntryIgnored { + preset_name, + entry_kind: LegacyEntryKind::InvalidChart, + count: 1, + } if preset_name == "BrokenCharts" + ))); + assert!(report.warnings.iter().any(|warning| matches!( + warning, + ImportWarning::LegacyCollectionSkipped { + collection_name: Some(name), + .. + } if name == "BrokenCharts" + ))); + } + + fn preset_snapshot(presets: &[Preset]) -> Vec<(String, Vec, Vec)> { + presets + .iter() + .map(|preset| { + ( + preset.name.clone(), + filter_definitions(preset), + search_value_definitions(preset), + ) + }) + .collect() + } + + fn filter_definitions(preset: &Preset) -> Vec { + preset + .filters + .iter() + .map(|entry| entry.filter.clone()) + .collect() + } +} diff --git a/crates/app/src/host/service/presets_io/mod.rs b/crates/app/src/host/service/presets_io/mod.rs new file mode 100644 index 0000000000..f6937ad714 --- /dev/null +++ b/crates/app/src/host/service/presets_io/mod.rs @@ -0,0 +1,235 @@ +//! Service-side import and export for named preset documents. +//! +//! This module owns the native on-disk JSON schema, parses the legacy export +//! shape for backward compatibility, and validates imported filters before the +//! UI applies them into the runtime preset registry. + +mod legacy; +mod v1; +mod v2; + +use std::fmt; + +use serde_json::Value; + +use processor::search::filter::SearchFilter; + +use crate::{ + common::validation::{ValidationEligibility, validate_filter, validate_search_value_filter}, + host::{message::ImportFormat, ui::registry::presets::Preset}, +}; + +/// Document kind written during serialization and required during import to +/// recognize preset files. +pub const DOCUMENT_KIND: &str = "chipmunk_named_presets"; + +/// Document version written during serialization and checked during import for +/// schema compatibility. +pub const DOCUMENT_VERSION: u8 = 2; + +/// Result returned after parsing a preset file successfully. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImportReport { + /// Format detected from the document root shape. + pub format: ImportFormat, + /// Imported preset snapshots with fresh runtime ids. + pub presets: Vec, + /// Non-fatal warnings collected during import. + pub warnings: Vec, +} + +/// Non-fatal issues collected while translating a legacy preset export. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ImportWarning { + /// A legacy collection could not be converted into a preset. + LegacyCollectionSkipped { + /// Legacy collection name when one was available. + collection_name: Option, + /// Reason the collection could not be imported. + reason: String, + }, + /// A legacy collection entry was ignored while its preset was imported. + LegacyEntryIgnored { + /// Preset name associated with the ignored entry. + preset_name: String, + /// Kind of legacy entry that was ignored. + entry_kind: LegacyEntryKind, + /// Number of ignored entries of this kind. + count: usize, + }, +} + +/// Legacy entry kind that was ignored during translation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LegacyEntryKind { + /// Bookmark entry. + Bookmark, + /// Filter entry that could not be parsed or validated. + InvalidFilter, + /// Chart entry that could not be parsed or validated. + InvalidChart, + /// Legacy entry object key that was not recognized. + Unsupported(String), +} + +/// Validate then serializes a preset snapshot into the native named-presets +/// JSON document. +pub fn serialize_named_presets(presets: Vec) -> Result { + v2::serialize_native_v2_presets(presets) +} + +/// Parses a native preset document or a supported legacy export. +/// +/// Returned presets already have fresh runtime ids assigned so the UI can hand +/// them to the registry import path directly. +pub fn import_named_presets(text: &str) -> Result { + let value = parse_root_value(text)?; + let (format, presets, warnings) = match value { + Value::Object(root) => { + let (format, presets) = parse_native_document_from_value(root)?; + (format, presets, Vec::new()) + } + Value::Array(items) => { + let (presets, warnings) = legacy::parse_legacy_from_value(items)?; + (ImportFormat::Legacy, presets, warnings) + } + _ => return Err("preset document root must be an object or array".to_owned()), + }; + + let report = ImportReport { + format, + presets, + warnings, + }; + + Ok(report) +} + +fn parse_root_value(text: &str) -> Result { + let trimmed = text.trim_start(); + serde_json::from_str(text).map_err(|err| { + // The legacy export uses a top-level array while the native format uses + // an object, so the first non-whitespace token is enough to classify + // syntax failures for the user-facing error. + if trimmed.starts_with('[') { + format!("invalid legacy preset export: {err}") + } else { + format!("invalid native preset document: {err}") + } + }) +} + +fn parse_native_document_from_value( + root: serde_json::Map, +) -> Result<(ImportFormat, Vec), String> { + let kind = root + .get("kind") + .and_then(Value::as_str) + .ok_or_else(|| "unsupported preset document kind: ".to_owned())?; + if kind != DOCUMENT_KIND { + return Err(format!("unsupported preset document kind: {kind}")); + } + + let version = root + .get("version") + .and_then(Value::as_u64) + .ok_or_else(|| "unsupported preset document version: 0".to_owned())?; + let version = u8::try_from(version).unwrap_or(u8::MAX); + + match version { + 1 => { + let presets = v1::parse_native_v1_document(root)?; + Ok((ImportFormat::Version1, presets)) + } + DOCUMENT_VERSION => { + let presets = v2::parse_native_v2_document(root)?; + Ok((ImportFormat::Version2, presets)) + } + _ => Err(format!("unsupported preset document version: {version}")), + } +} + +/// Validates that a preset name is usable in native imports and exports. +pub fn validate_name(name: &str) -> Result<(), String> { + if name.trim().is_empty() { + return Err("preset name cannot be blank".to_owned()); + } + Ok(()) +} + +/// Validates a filter row for a named preset. +pub fn validate_filter_entry(preset_name: &str, filter: &SearchFilter) -> Result<(), String> { + match validate_filter(filter) { + ValidationEligibility::Eligible => Ok(()), + ValidationEligibility::Ineligible { reason } => Err(format!( + "invalid filter in preset '{preset_name}': {reason}" + )), + } +} + +/// Validates a search-value row for a named preset. +pub fn validate_search_value_entry(preset_name: &str, filter: &SearchFilter) -> Result<(), String> { + match validate_search_value_filter(filter) { + ValidationEligibility::Eligible => Ok(()), + ValidationEligibility::Ineligible { reason } => Err(format!( + "invalid search value in preset '{preset_name}': {reason}" + )), + } +} + +impl fmt::Display for LegacyEntryKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Bookmark => f.write_str("bookmark"), + Self::InvalidFilter => f.write_str("invalid filter"), + Self::InvalidChart => f.write_str("invalid chart"), + Self::Unsupported(kind) => f.write_str(kind), + } + } +} + +impl fmt::Display for ImportWarning { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::LegacyCollectionSkipped { + collection_name, + reason, + } => match collection_name { + Some(name) => write!(f, "Skipped legacy collection '{name}': {reason}."), + None => write!(f, "Skipped a legacy collection: {reason}."), + }, + Self::LegacyEntryIgnored { + preset_name, + entry_kind, + count, + } => { + let noun = if *count == 1 { "entry" } else { "entries" }; + write!( + f, + "Ignored {count} {entry_kind} {noun} while importing preset '{preset_name}'." + ) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn native_rejects_kind() { + import_named_presets(r#"{"kind":"wrong","version":2,"presets":[]}"#).unwrap_err(); + } + + #[test] + fn native_rejects_version() { + import_named_presets(r#"{"kind":"chipmunk_named_presets","version":3,"presets":[]}"#) + .unwrap_err(); + } + + #[test] + fn import_rejects_object_without_native_kind() { + import_named_presets(r#"{"presets":[]}"#).unwrap_err(); + } +} diff --git a/crates/app/src/host/service/presets_io/v1.rs b/crates/app/src/host/service/presets_io/v1.rs new file mode 100644 index 0000000000..d234c1ed35 --- /dev/null +++ b/crates/app/src/host/service/presets_io/v1.rs @@ -0,0 +1,168 @@ +//! Native v1 preset import compatibility. +//! +//! Unlike v2, v1 stores only filter/search-value definitions. Row enabled +//! state and colors are not present, so conversion immediately applies runtime +//! default row state. +//! +//! +//! V1 import shape: +//! ```json +//! { +//! "kind": "chipmunk_named_presets", +//! "version": 1, +//! "presets": [ +//! { +//! "name": "...", +//! "filters": [ { "value": "search", "is_regex": true, "ignore_case": true, "is_word": false } ], +//! "search_values": [ { "value": "time=([\\d.]{1,})", "is_regex": true, "ignore_case": true, "is_word": false } ] +//! } +//! ] +//! } +//! ``` + +use processor::search::filter::SearchFilter; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +use crate::host::ui::registry::presets::Preset; + +use super::{validate_filter_entry, validate_name, validate_search_value_entry}; + +/// Preset payload stored in native v1 preset documents. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct NativePresetV1 { + name: String, + filters: Vec, + search_values: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct NativeDocumentV1 { + kind: String, + version: u8, + presets: Vec, +} + +/// Parses a native v1 document object into runtime presets with default row state. +pub fn parse_native_v1_document( + root: serde_json::Map, +) -> Result, String> { + let document: NativeDocumentV1 = serde_json::from_value(Value::Object(root)) + .map_err(|err| format!("invalid native preset document: {err}"))?; + document + .presets + .iter() + .try_for_each(validate_native_preset_v1)?; + let presets = document.presets.into_iter().map(Preset::from).collect(); + + Ok(presets) +} + +fn validate_native_preset_v1(preset: &NativePresetV1) -> Result<(), String> { + let NativePresetV1 { + name, + filters, + search_values, + } = preset; + + validate_name(name)?; + + for filter in filters { + validate_filter_entry(name, filter)?; + } + + for search_value in search_values { + validate_search_value_entry(name, search_value)?; + } + + Ok(()) +} + +impl From for Preset { + fn from(value: NativePresetV1) -> Self { + Preset::with_default_state( + Uuid::new_v4(), + value.name, + value.filters, + value.search_values, + ) + } +} + +#[cfg(test)] +mod tests { + use processor::search::filter::SearchFilter; + + use crate::host::{ + common::colors, message::ImportFormat, service::presets_io::import_named_presets, + ui::registry::presets::Preset, + }; + + fn plain(value: &str) -> SearchFilter { + SearchFilter::plain(value).ignore_case(true) + } + + fn regex(value: &str) -> SearchFilter { + SearchFilter::plain(value).regex(true).ignore_case(true) + } + + fn filter_definitions(preset: &Preset) -> Vec { + preset + .filters + .iter() + .map(|entry| entry.filter.clone()) + .collect() + } + + fn search_value_definitions(preset: &Preset) -> Vec { + preset + .search_values + .iter() + .map(|entry| entry.filter.clone()) + .collect() + } + + #[test] + fn import_v1_document_uses_default_state() { + let json = r#" + { + "kind": "chipmunk_named_presets", + "version": 1, + "presets": [ + { + "name": "Errors", + "filters": [ + { "value": "error", "is_regex": false, "ignore_case": true, "is_word": false }, + { "value": "error", "is_regex": false, "ignore_case": true, "is_word": false } + ], + "search_values": [ + { "value": "duration=(\\d+)", "is_regex": true, "ignore_case": true, "is_word": false }, + { "value": "duration=(\\d+)", "is_regex": true, "ignore_case": true, "is_word": false } + ] + } + ] + } + "#; + + let report = import_named_presets(json).unwrap(); + let preset = &report.presets[0]; + + assert_eq!(report.format, ImportFormat::Version1); + assert!(report.warnings.is_empty()); + assert_eq!( + filter_definitions(preset), + vec![plain("error"), plain("error")] + ); + assert!(preset.filters.iter().all(|entry| entry.enabled)); + assert_eq!(preset.filters[0].colors, colors::FILTER_HIGHLIGHT_COLORS[0]); + assert_eq!(preset.filters[1].colors, colors::FILTER_HIGHLIGHT_COLORS[1]); + assert_eq!( + search_value_definitions(preset), + vec![regex("duration=(\\d+)"), regex("duration=(\\d+)")] + ); + assert!(preset.search_values.iter().all(|entry| entry.enabled)); + assert_eq!(preset.search_values[0].color, colors::search_value_color(0)); + assert_eq!(preset.search_values[1].color, colors::search_value_color(1)); + } +} diff --git a/crates/app/src/host/service/presets_io/v2.rs b/crates/app/src/host/service/presets_io/v2.rs new file mode 100644 index 0000000000..b943850200 --- /dev/null +++ b/crates/app/src/host/service/presets_io/v2.rs @@ -0,0 +1,411 @@ +//! Current native preset document serialization and import. +//! +//! Version 2 native documents store named filter and search-value row snapshots, +//! including enabled state and colors. + +use egui::Color32; +use processor::search::filter::SearchFilter; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +use crate::host::{ + common::colors::ColorPair, + ui::registry::presets::{Preset, PresetFilterEntry, PresetSearchValueEntry}, +}; + +use super::{ + DOCUMENT_KIND, DOCUMENT_VERSION, validate_filter_entry, validate_name, + validate_search_value_entry, +}; + +type Rgba = [u8; 4]; + +/// Preset payload stored in the current native preset document. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct NativePresetV2 { + name: String, + filters: Vec, + search_values: Vec, +} + +/// Filter row payload stored in the current native preset document. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct NativeFilterEntry { + filter: SearchFilter, + enabled: bool, + colors: NativeColorPair, +} + +/// Chart/search-value row payload stored in the current native preset document. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct NativeSearchValueEntry { + filter: SearchFilter, + enabled: bool, + color: Rgba, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct NativeColorPair { + fg: Rgba, + bg: Rgba, +} + +/// Versioned preset document stored on disk. +#[derive(Debug, Serialize, Deserialize)] +struct NativeDocumentV2 { + kind: String, + version: u8, + presets: Vec, +} + +/// Serializes runtime presets into the native v2 document format. +pub fn serialize_native_v2_presets(presets: Vec) -> Result { + // Runtime preset ids are intentionally ignored here because the file format + // stores only named row snapshots. + let presets = presets.into_iter().map(NativePresetV2::from).collect(); + + serialize_native_v2_document(presets) +} + +fn serialize_native_v2_document(presets: Vec) -> Result { + presets.iter().try_for_each(validate_native_preset_v2)?; + + let document = NativeDocumentV2 { + kind: DOCUMENT_KIND.to_owned(), + version: DOCUMENT_VERSION, + presets, + }; + + serde_json::to_string_pretty(&document) + .map_err(|err| format!("invalid native preset document: {err}")) +} + +/// Parses a native v2 document object into runtime presets. +pub fn parse_native_v2_document( + root: serde_json::Map, +) -> Result, String> { + let document: NativeDocumentV2 = serde_json::from_value(Value::Object(root)) + .map_err(|err| format!("invalid native preset document: {err}"))?; + document + .presets + .iter() + .try_for_each(validate_native_preset_v2)?; + let presets = document.presets.into_iter().map(Preset::from).collect(); + + Ok(presets) +} + +fn validate_native_preset_v2(preset: &NativePresetV2) -> Result<(), String> { + let NativePresetV2 { + name, + filters, + search_values, + } = preset; + + validate_name(name)?; + + for entry in filters { + let NativeFilterEntry { + filter, + enabled: _, + colors: _, + } = entry; + validate_filter_entry(name, filter)?; + } + + for entry in search_values { + let NativeSearchValueEntry { + filter, + enabled: _, + color: _, + } = entry; + validate_search_value_entry(name, filter)?; + } + + Ok(()) +} + +impl From for NativePresetV2 { + fn from(value: Preset) -> Self { + Self { + name: value.name, + filters: value + .filters + .into_iter() + .map(NativeFilterEntry::from) + .collect(), + search_values: value + .search_values + .into_iter() + .map(NativeSearchValueEntry::from) + .collect(), + } + } +} + +impl From for NativeFilterEntry { + fn from(value: PresetFilterEntry) -> Self { + Self { + filter: value.filter, + enabled: value.enabled, + colors: NativeColorPair::from(value.colors), + } + } +} + +impl From for NativeSearchValueEntry { + fn from(value: PresetSearchValueEntry) -> Self { + Self { + filter: value.filter, + enabled: value.enabled, + color: color_to_rgba(value.color), + } + } +} + +impl From for NativeColorPair { + fn from(value: ColorPair) -> Self { + Self { + fg: color_to_rgba(value.fg), + bg: color_to_rgba(value.bg), + } + } +} + +impl From for Preset { + fn from(value: NativePresetV2) -> Self { + Self { + // Import always creates fresh runtime ids. Name collision handling is + // deferred to the UI registry import path. + id: Uuid::new_v4(), + name: value.name, + filters: value + .filters + .into_iter() + .map(PresetFilterEntry::from) + .collect(), + search_values: value + .search_values + .into_iter() + .map(PresetSearchValueEntry::from) + .collect(), + } + } +} + +impl From for PresetFilterEntry { + fn from(value: NativeFilterEntry) -> Self { + Self::new(value.filter, value.enabled, ColorPair::from(value.colors)) + } +} + +impl From for PresetSearchValueEntry { + fn from(value: NativeSearchValueEntry) -> Self { + Self::new(value.filter, value.enabled, color_from_rgba(value.color)) + } +} + +impl From for ColorPair { + fn from(value: NativeColorPair) -> Self { + Self::new(color_from_rgba(value.fg), color_from_rgba(value.bg)) + } +} + +fn color_to_rgba(color: Color32) -> Rgba { + color.to_srgba_unmultiplied() +} + +fn color_from_rgba([r, g, b, a]: Rgba) -> Color32 { + Color32::from_rgba_unmultiplied(r, g, b, a) +} + +#[cfg(test)] +mod tests { + use egui::Color32; + use processor::search::filter::SearchFilter; + use rustc_hash::FxHashSet; + use uuid::Uuid; + + use crate::host::{ + common::colors::ColorPair, + message::ImportFormat, + service::presets_io::{import_named_presets, serialize_named_presets}, + ui::registry::presets::{Preset, PresetFilterEntry, PresetSearchValueEntry}, + }; + + fn plain(value: &str) -> SearchFilter { + SearchFilter::plain(value).ignore_case(true) + } + + fn regex(value: &str) -> SearchFilter { + SearchFilter::plain(value).regex(true).ignore_case(true) + } + + fn preset(name: &str, filters: Vec, search_values: Vec) -> Preset { + Preset::with_default_state(Uuid::new_v4(), name.to_owned(), filters, search_values) + } + + fn preset_snapshot(presets: &[Preset]) -> Vec<(String, Vec, Vec)> { + presets + .iter() + .map(|preset| { + ( + preset.name.clone(), + filter_definitions(preset), + search_value_definitions(preset), + ) + }) + .collect() + } + + fn filter_definitions(preset: &Preset) -> Vec { + preset + .filters + .iter() + .map(|entry| entry.filter.clone()) + .collect() + } + + fn search_value_definitions(preset: &Preset) -> Vec { + preset + .search_values + .iter() + .map(|entry| entry.filter.clone()) + .collect() + } + + #[test] + fn native_v2_round_trip_preserves_state() { + let source = vec![Preset { + id: Uuid::new_v4(), + name: "Errors".to_owned(), + filters: vec![PresetFilterEntry::new( + plain("error"), + false, + ColorPair::new( + Color32::from_rgba_unmultiplied(1, 2, 3, 4), + Color32::from_rgba_unmultiplied(5, 6, 7, 8), + ), + )], + search_values: vec![PresetSearchValueEntry::new( + regex("duration=(\\d+)"), + false, + Color32::from_rgba_unmultiplied(9, 10, 11, 12), + )], + }]; + + let json = serialize_named_presets(source.clone()).unwrap(); + let parsed = import_named_presets(&json).unwrap(); + + assert!(json.contains("\"version\": 2")); + assert_eq!(parsed.format, ImportFormat::Version2); + assert_eq!(preset_snapshot(&parsed.presets), preset_snapshot(&source)); + assert!(!parsed.presets[0].filters[0].enabled); + assert_eq!( + parsed.presets[0].filters[0].colors, + source[0].filters[0].colors + ); + assert!(!parsed.presets[0].search_values[0].enabled); + assert_eq!( + parsed.presets[0].search_values[0].color, + source[0].search_values[0].color + ); + } + + #[test] + fn native_rejects_blank_name() { + import_named_presets( + r#"{"kind":"chipmunk_named_presets","version":2,"presets":[{"name":" ","filters":[],"search_values":[]}]}"#, + ) + .unwrap_err(); + } + + #[test] + fn native_rejects_invalid_filter() { + import_named_presets( + r#"{"kind":"chipmunk_named_presets","version":2,"presets":[{"name":"Broken","filters":[{"filter":{"value":"(","is_regex":true,"ignore_case":true,"is_word":false},"enabled":true,"colors":{"fg":[255,255,255,255],"bg":[0,0,0,255]}}],"search_values":[]}]}"#, + ) + .unwrap_err(); + } + + #[test] + fn native_rejects_invalid_search_value() { + import_named_presets( + r#"{"kind":"chipmunk_named_presets","version":2,"presets":[{"name":"Broken","filters":[],"search_values":[{"filter":{"value":"cpu=(.+)","is_regex":true,"ignore_case":true,"is_word":false},"enabled":true,"color":[255,255,255,255]}]}]}"#, + ) + .unwrap_err(); + } + + #[test] + fn export_never_uses_legacy_shape() { + let presets = vec![preset( + "Errors", + vec![plain("error")], + vec![regex("duration=(\\d+)")], + )]; + + let json = serialize_named_presets(presets).unwrap(); + + assert!(json.contains("\"kind\": \"chipmunk_named_presets\"")); + assert!(json.contains("\"search_values\"")); + assert!(json.contains("\"is_regex\"")); + assert!(!json.contains("\"searchValues\"")); + assert!(!json.contains("\"content\"")); + assert!(!json.contains("\"uuid\"")); + } + + #[test] + fn serialize_rejects_invalid_search_value() { + let presets = vec![preset("Broken", vec![], vec![regex("cpu=(.+)")])]; + + serialize_named_presets(presets).unwrap_err(); + } + + #[test] + fn import_native_document_preserves_duplicate_names() { + let json = r#" + { + "kind": "chipmunk_named_presets", + "version": 2, + "presets": [ + { "name": "Same", "filters": [], "search_values": [] }, + { "name": "Same", "filters": [], "search_values": [] } + ] + } + "#; + + let report = import_named_presets(json).unwrap(); + + assert_eq!(report.presets.len(), 2); + assert_eq!(report.presets[0].name, "Same"); + assert_eq!(report.presets[1].name, "Same"); + let ids = report + .presets + .iter() + .map(|preset| preset.id) + .collect::>(); + assert_eq!(ids.len(), 2); + } + + #[test] + fn export_then_import_matches_definitions() { + let source = vec![ + preset( + "Errors", + vec![plain("error"), plain("warn"), plain("error")], + vec![ + regex("duration=(\\d+)"), + regex("latency=(\\d+)"), + regex("duration=(\\d+)"), + ], + ), + preset("Charts", vec![], vec![regex("temp=(\\d+)")]), + ]; + + let json = serialize_named_presets(source.clone()).unwrap(); + let report = import_named_presets(&json).unwrap(); + + assert_eq!(preset_snapshot(&report.presets), preset_snapshot(&source)); + } +} diff --git a/crates/app/src/host/ui/notification/banner.rs b/crates/app/src/host/ui/notification/banner.rs new file mode 100644 index 0000000000..0a76cef03c --- /dev/null +++ b/crates/app/src/host/ui/notification/banner.rs @@ -0,0 +1,138 @@ +//! Latest notification banner rendering. + +use std::time::{Duration, Instant}; + +use egui::{ + Align2, Area, Color32, Frame, Id, Label, Margin, NumExt, Order, Rect, RichText, Sense, Stroke, + TextWrapMode, Ui, Vec2, pos2, +}; + +use super::NotificationEntry; + +/// State for the currently visible temporal notification banner. +#[derive(Debug, Clone)] +pub struct NotificationBanner { + entry: NotificationEntry, + remaining: Duration, + created_at: Instant, + last_updated: Instant, + // The banner uses a stable egui Area id, so egui keeps the previous Area size. + // When true, the next render uses a sizing pass to drop stale cached height. + reset_cached_size: bool, +} + +impl NotificationBanner { + pub fn new(entry: NotificationEntry) -> Self { + const BANNER_TTL: Duration = Duration::from_secs(4); + + let now = Instant::now(); + Self { + entry, + remaining: BANNER_TTL, + created_at: now, + last_updated: now, + reset_cached_size: true, + } + } + + pub fn render(&mut self, button_rect: Rect, ui: &mut Ui) -> bool { + const BANNER_MAX_WIDTH: f32 = 340.0; + const BANNER_MARGIN: f32 = 8.0; + const BANNER_GAP: f32 = 6.0; + + let content_rect = ui.ctx().content_rect(); + let available_width = content_rect.width() - BANNER_MARGIN * 2.0; + let banner_width = BANNER_MAX_WIDTH.min(available_width.at_least(20.0)); + let pos = pos2( + button_rect + .right() + .min(content_rect.right() - BANNER_MARGIN), + button_rect.bottom() + BANNER_GAP, + ); + + let now = Instant::now(); + const FADE_IN: Duration = Duration::from_millis(120); + const FADE_OUT: Duration = Duration::from_millis(400); + let age_alpha = (now.saturating_duration_since(self.created_at).as_secs_f32() + / FADE_IN.as_secs_f32()) + .clamp(0.0, 1.0); + let remaining_alpha = + (self.remaining.as_secs_f32() / FADE_OUT.as_secs_f32()).clamp(0.0, 1.0); + let opacity = age_alpha.min(remaining_alpha); + let apply_alpha = |color: Color32| { + let alpha = (color.a() as f32 * opacity).round() as u8; + Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), alpha) + }; + + let entry = &self.entry; + let banner_id = Id::new("notification_banner"); + let response = Area::new(banner_id) + .order(Order::Foreground) + .pivot(Align2::RIGHT_TOP) + .fixed_pos(pos) + // Reset egui's cached Area size when a new notification replaces the old one. + .sizing_pass(self.reset_cached_size) + .show(ui.ctx(), |ui| { + ui.set_width(banner_width); + + let base_level_color = entry.level.color(ui.visuals().dark_mode); + let level_color = apply_alpha(base_level_color); + let stroke = Stroke::new(1.0, level_color); + let margin = Margin::same(10); + let content_width = (banner_width - 20.0).at_least(0.0); + let text_color = apply_alpha(ui.visuals().text_color()); + let mut frame = Frame::window(ui.style()) + .stroke(stroke) + .inner_margin(margin); + frame.fill = apply_alpha(frame.fill); + frame.shadow.color = apply_alpha(frame.shadow.color); + frame + .show(ui, |ui| { + ui.set_min_width(content_width); + ui.horizontal_centered(|ui| { + let dot_size = Vec2::splat(8.0); + let (respond, painter) = ui.allocate_painter(dot_size, Sense::empty()); + let dot_radius = respond.rect.width() / 2.0; + painter.circle_filled(respond.rect.center(), dot_radius, level_color); + + let message = + Label::new(RichText::new(&entry.message).color(text_color)) + .wrap_mode(TextWrapMode::Wrap); + ui.add(message); + }); + }) + .response + .interact(Sense::click()) + }) + .inner; + self.reset_cached_size = false; + + if response.clicked() { + return true; + } + + let hovered = response.hovered() + || ui + .ctx() + .rect_contains_pointer(response.layer_id, response.interact_rect); + if hovered { + self.last_updated = now; + } else { + let elapsed = now.saturating_duration_since(self.last_updated); + self.last_updated = now; + self.remaining = self.remaining.saturating_sub(elapsed); + } + + if !self.expired() { + // Some platforms do not reliably wake the app for delayed repaints here. + // Keep the repaint loop alive only while a banner is visible. + ui.ctx().request_repaint(); + } + + false + } + + pub fn expired(&self) -> bool { + self.remaining.is_zero() + } +} diff --git a/crates/app/src/host/ui/notification.rs b/crates/app/src/host/ui/notification/mod.rs similarity index 64% rename from crates/app/src/host/ui/notification.rs rename to crates/app/src/host/ui/notification/mod.rs index 6ab23996b1..06006c0a6b 100644 --- a/crates/app/src/host/ui/notification.rs +++ b/crates/app/src/host/ui/notification/mod.rs @@ -1,12 +1,13 @@ //! Host notification UI, including the history popup and latest-message banner. -use std::time::{Duration, Instant}; +mod banner; use egui::{ - Align2, Area, Button, Color32, Frame, Id, Label, Layout, Margin, Modal, ModalResponse, NumExt, - Order, Popup, PopupAnchor, Rect, RectAlign, RichText, ScrollArea, Sense, Stroke, TextWrapMode, + Align, Button, Color32, Frame, Id, Label, Layout, Margin, Modal, ModalResponse, NumExt, Popup, + PopupAnchor, PopupCloseBehavior, Rect, RectAlign, RichText, ScrollArea, Sense, TextWrapMode, Ui, Vec2, pos2, vec2, }; +use stypes::Severity; use crate::{ common::{ @@ -17,8 +18,7 @@ use crate::{ session::error::SessionError, }; -/// The maximum amount of notifications to store. -const NOTIFICATIONS_LIMIT: usize = 30; +use self::banner::NotificationBanner; /// Stores host notifications and renders their button, popup, modal, and banner. #[derive(Debug)] @@ -47,18 +47,11 @@ enum NotificationLevel { Error, } -/// State for the currently visible temporal notification banner. -#[derive(Debug, Clone)] -struct NotificationBanner { - entry: NotificationEntry, - remaining: Duration, - created_at: Instant, - last_updated: Instant, -} - impl Default for NotificationUi { /// Creates empty notification UI state. fn default() -> Self { + const NOTIFICATIONS_LIMIT: usize = 30; + Self { popup_id: egui::Id::new("notification_popup"), queue: FixedQueue::new(NOTIFICATIONS_LIMIT), @@ -82,16 +75,8 @@ impl NotificationUi { self.unseen_top_level = Some(entry.level); } - // Banner time to live - const BANNER_TTL: Duration = Duration::from_secs(4); - - let now = Instant::now(); - self.active_banner = Some(NotificationBanner { - entry: entry.clone(), - remaining: BANNER_TTL, - created_at: now, - last_updated: now, - }); + let active_banner = NotificationBanner::new(entry.clone()); + self.active_banner = Some(active_banner); self.queue.add_item(entry); } @@ -121,13 +106,15 @@ impl NotificationUi { } None => { const POPUP_GAP: f32 = 3.0; + let anchor_pos = pos2( + ui.content_rect().right() - POPUP_GAP, + ui.max_rect().bottom() + POPUP_GAP, + ); + let popup_anchor = PopupAnchor::Position(anchor_pos); Popup::menu(&button_res) .id(self.popup_id) - .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) - .anchor(PopupAnchor::Position(pos2( - ui.content_rect().right() - POPUP_GAP, - ui.max_rect().bottom() + POPUP_GAP, - ))) + .close_behavior(PopupCloseBehavior::CloseOnClickOutside) + .anchor(popup_anchor) .align(RectAlign::BOTTOM_END) .show(|ui| { self.active_banner = None; @@ -157,100 +144,13 @@ impl NotificationUi { return; }; - const BANNER_MAX_WIDTH: f32 = 340.0; - const BANNER_MARGIN: f32 = 8.0; - const BANNER_GAP: f32 = 6.0; - - let content_rect = ui.ctx().content_rect(); - let available_width = content_rect.width() - BANNER_MARGIN * 2.0; - let banner_width = BANNER_MAX_WIDTH.min(available_width.at_least(20.0)); - let pos = pos2( - button_rect - .right() - .min(content_rect.right() - BANNER_MARGIN), - button_rect.bottom() + BANNER_GAP, - ); - - let now = Instant::now(); - const FADE_IN: Duration = Duration::from_millis(120); - const FADE_OUT: Duration = Duration::from_millis(400); - let age_alpha = (now - .saturating_duration_since(banner.created_at) - .as_secs_f32() - / FADE_IN.as_secs_f32()) - .clamp(0.0, 1.0); - let remaining_alpha = - (banner.remaining.as_secs_f32() / FADE_OUT.as_secs_f32()).clamp(0.0, 1.0); - let opacity = age_alpha.min(remaining_alpha); - let apply_alpha = |color: Color32| { - let alpha = (color.a() as f32 * opacity).round() as u8; - Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), alpha) - }; - - let entry = &banner.entry; - let banner_id = Id::new("notification_banner"); - let response = Area::new(banner_id) - .order(Order::Foreground) - .pivot(Align2::RIGHT_TOP) - .fixed_pos(pos) - .show(ui.ctx(), |ui| { - ui.set_width(banner_width); - - let level_color = apply_alpha(entry.level.color(ui.visuals().dark_mode)); - let stroke = Stroke::new(1.0, level_color); - let margin = Margin::same(10); - let content_width = (banner_width - 20.0).at_least(0.0); - let text_color = apply_alpha(ui.visuals().text_color()); - let mut frame = Frame::window(ui.style()) - .stroke(stroke) - .inner_margin(margin); - frame.fill = apply_alpha(frame.fill); - frame.shadow.color = apply_alpha(frame.shadow.color); - frame - .show(ui, |ui| { - ui.set_min_width(content_width); - ui.horizontal_centered(|ui| { - let dot_size = Vec2::splat(8.0); - let (respond, painter) = ui.allocate_painter(dot_size, Sense::empty()); - let dot_radius = respond.rect.width() / 2.0; - painter.circle_filled(respond.rect.center(), dot_radius, level_color); - - let message = - Label::new(RichText::new(&entry.message).color(text_color)) - .wrap_mode(TextWrapMode::Wrap); - ui.add(message); - }); - }) - .response - .interact(Sense::click()) - }) - .inner; - - if response.clicked() { + let clicked = banner.render(button_rect, ui); + if clicked { // Dismiss the banner and mark notifications as seen on click. self.active_banner = None; self.unseen_top_level = None; - return; - } - - let hovered = response.hovered() - || ui - .ctx() - .rect_contains_pointer(response.layer_id, response.interact_rect); - if hovered { - banner.last_updated = now; - } else { - let elapsed = now.saturating_duration_since(banner.last_updated); - banner.last_updated = now; - banner.remaining = banner.remaining.saturating_sub(elapsed); - } - - if banner.remaining.is_zero() { + } else if banner.expired() { self.active_banner = None; - } else { - // Some platforms do not reliably wake the app for delayed repaints here. - // Keep the repaint loop alive only while a banner is visible. - ui.ctx().request_repaint(); } } @@ -274,7 +174,7 @@ impl NotificationUi { ui.label(format!("Notifications ({})", self.queue.len())); } - ui.with_layout(Layout::right_to_left(egui::Align::TOP), |ui| { + ui.with_layout(Layout::right_to_left(Align::TOP), |ui| { let close_res = ui.button(icons::regular::X).on_hover_text("Close"); if close_res.clicked() { @@ -294,7 +194,7 @@ impl NotificationUi { }); // Notifications - egui::ScrollArea::vertical() + ScrollArea::vertical() .min_scrolled_height(panel_height / 2.) .max_height(panel_height) .show(ui, |ui| { @@ -374,8 +274,8 @@ impl From<&AppNotification> for NotificationLevel { Not::HostError(HostError::InitSessionError(..)) => Level::Error, Not::HostError(HostError::NativeError(err)) | Not::SessionError(SessionError::NativeError(err)) => match err.severity { - stypes::Severity::WARNING => Level::Warning, - stypes::Severity::ERROR => Level::Error, + Severity::WARNING => Level::Warning, + Severity::ERROR => Level::Error, }, Not::SessionError(..) => Level::Error, Not::Error(..) | Not::UiError(..) => Level::Error, diff --git a/crates/app/src/host/ui/registry/mod.rs b/crates/app/src/host/ui/registry/mod.rs index 800888e9c3..3a63bff43e 100644 --- a/crates/app/src/host/ui/registry/mod.rs +++ b/crates/app/src/host/ui/registry/mod.rs @@ -35,10 +35,16 @@ mod tests { #[test] fn cleanup_keeps_presets() { let mut registry = HostRegistry::default(); + let preset = presets::Preset::with_default_state( + Uuid::new_v4(), + "Errors".to_owned(), + vec![SearchFilter::plain("error")], + vec![], + ); let preset_id = registry .presets - .add_preset("Errors", vec![SearchFilter::plain("error")], vec![]); + .add_preset(preset.name, preset.filters, preset.search_values); registry.cleanup_session(&Uuid::new_v4()); diff --git a/crates/app/src/host/ui/registry/presets/capture.rs b/crates/app/src/host/ui/registry/presets/capture.rs new file mode 100644 index 0000000000..0217aaf77f --- /dev/null +++ b/crates/app/src/host/ui/registry/presets/capture.rs @@ -0,0 +1,170 @@ +//! Session capture for named preset snapshots. + +use uuid::Uuid; + +use crate::{host::ui::registry::filters::FilterRegistry, session::ui::SessionShared}; + +use super::{PresetFilterEntry, PresetRegistry, PresetSearchValueEntry}; + +impl PresetRegistry { + /// Captures the current session-applied filters and charts as a preset. + pub fn add_preset_from_session( + &mut self, + shared: &SessionShared, + registry: &FilterRegistry, + ) -> Uuid { + let filters = shared + .filters + .filter_entries + .iter() + .filter_map(|item| { + registry.get_filter(&item.id).map(|def| { + PresetFilterEntry::new(def.filter.clone(), item.enabled, item.colors.clone()) + }) + }) + .collect(); + let search_values = shared + .filters + .search_value_entries + .iter() + .filter_map(|item| { + registry.get_search_value(&item.id).map(|def| { + PresetSearchValueEntry::new(def.filter.clone(), item.enabled, item.color) + }) + }) + .collect(); + + self.add_preset(shared.get_info().title.clone(), filters, search_values) + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use processor::search::filter::SearchFilter; + use stypes::{FileFormat, ObserveOrigin}; + use uuid::Uuid; + + use crate::{ + host::{ + common::parsers::ParserNames, + ui::registry::filters::{FilterDefinition, FilterRegistry, SearchValueDefinition}, + }, + session::{types::ObserveOperation, ui::SessionInfo}, + }; + + use super::*; + use crate::session::ui::definitions::schema::LogSchemaSpec; + + fn new_shared() -> SessionShared { + let session_id = Uuid::new_v4(); + let origin = ObserveOrigin::File( + "source".to_owned(), + FileFormat::Text, + PathBuf::from("source.log"), + ); + let observe_op = ObserveOperation::new(Uuid::new_v4(), origin); + let session_info = SessionInfo { + id: session_id, + title: "test".to_owned(), + parser: ParserNames::Text, + raw_export_supported: false, + }; + + SessionShared::new(session_info, observe_op, LogSchemaSpec::Text) + } + + fn add_filter_definition(registry: &mut FilterRegistry, value: &str) -> Uuid { + let definition = FilterDefinition::new(SearchFilter::plain(value).ignore_case(true)); + let id = definition.id; + registry.add_filter(definition); + id + } + + fn add_search_value_definition(registry: &mut FilterRegistry, value: &str) -> Uuid { + let definition = + SearchValueDefinition::new(SearchFilter::plain(value).regex(true).ignore_case(true)); + let id = definition.id; + registry.add_search_value(definition); + id + } + + #[test] + fn captures_applied_session_state() { + let mut shared = new_shared(); + let mut filters_registry = FilterRegistry::default(); + let mut preset_registry = PresetRegistry::default(); + let first_filter_id = add_filter_definition(&mut filters_registry, "error"); + let second_filter_id = add_filter_definition(&mut filters_registry, "warn"); + let first_value_id = add_search_value_definition(&mut filters_registry, "duration=(\\d+)"); + let second_value_id = add_search_value_definition(&mut filters_registry, "latency=(\\d+)"); + + shared + .filters + .apply_filter(&mut filters_registry, first_filter_id); + shared + .filters + .apply_filter_with_state(&mut filters_registry, second_filter_id, false); + shared + .filters + .apply_search_value(&mut filters_registry, first_value_id); + shared + .filters + .apply_search_value_with_state(&mut filters_registry, second_value_id, false); + + let expected_filter_entries = shared.filters.filter_entries.clone(); + let expected_search_value_entries = shared.filters.search_value_entries.clone(); + let preset_id = preset_registry.add_preset_from_session(&shared, &filters_registry); + let preset = preset_registry.get(&preset_id).unwrap(); + + assert_eq!(preset.name, "test"); + let first_filter = filters_registry + .get_filter(&first_filter_id) + .unwrap() + .filter + .clone(); + let second_filter = filters_registry + .get_filter(&second_filter_id) + .unwrap() + .filter + .clone(); + let expected_filters = vec![ + PresetFilterEntry::new( + first_filter, + expected_filter_entries[0].enabled, + expected_filter_entries[0].colors.clone(), + ), + PresetFilterEntry::new( + second_filter, + expected_filter_entries[1].enabled, + expected_filter_entries[1].colors.clone(), + ), + ]; + assert_eq!(preset.filters, expected_filters); + + let first_search_value = filters_registry + .get_search_value(&first_value_id) + .unwrap() + .filter + .clone(); + let second_search_value = filters_registry + .get_search_value(&second_value_id) + .unwrap() + .filter + .clone(); + let expected_search_values = vec![ + PresetSearchValueEntry::new( + first_search_value, + expected_search_value_entries[0].enabled, + expected_search_value_entries[0].color, + ), + PresetSearchValueEntry::new( + second_search_value, + expected_search_value_entries[1].enabled, + expected_search_value_entries[1].color, + ), + ]; + assert_eq!(preset.search_values, expected_search_values); + } +} diff --git a/crates/app/src/host/ui/registry/presets.rs b/crates/app/src/host/ui/registry/presets/catalog.rs similarity index 65% rename from crates/app/src/host/ui/registry/presets.rs rename to crates/app/src/host/ui/registry/presets/catalog.rs index 21b2e1f4ae..430a29bb51 100644 --- a/crates/app/src/host/ui/registry/presets.rs +++ b/crates/app/src/host/ui/registry/presets/catalog.rs @@ -1,10 +1,11 @@ -use std::borrow::Cow; +//! Preset catalog storage and mutation behavior. -use crate::{host::ui::registry::filters::FilterRegistry, session::ui::SessionShared}; -use processor::search::filter::SearchFilter; +use std::borrow::Cow; use uuid::Uuid; +use super::{Preset, PresetFilterEntry, PresetSearchValueEntry}; + /// Host-level registry for named preset snapshots captured from session filters and charts. #[derive(Debug, Default, Clone)] pub struct PresetRegistry { @@ -13,20 +14,14 @@ pub struct PresetRegistry { definitions_revision: u64, } -/// Preset definition with copied semantic content. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Preset { - pub id: Uuid, - pub name: String, - pub filters: Vec, - pub search_values: Vec, -} - /// Result of applying a preset edit request. #[derive(Debug, Clone, PartialEq, Eq)] pub enum PresetUpdateOutcome { + /// No preset exists for the requested id. NotFound, + /// The requested edit matched the stored preset. Unchanged, + /// The preset was updated. Updated { /// Final stored name after uniqueness normalization. name: String, @@ -36,10 +31,12 @@ pub enum PresetUpdateOutcome { /// Result of importing a batch of presets into the registry. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct PresetImportSummary { + /// Number of imported presets renamed to avoid collisions. pub renamed_items: usize, } impl PresetRegistry { + /// Returns stored presets in display order. pub fn presets(&self) -> &[Preset] { &self.presets } @@ -50,37 +47,47 @@ impl PresetRegistry { } fn unique_preset_name<'a>(&self, base_name: &'a str, skip_id: Option) -> Cow<'a, str> { - if !self - .presets - .iter() - .any(|preset| Some(preset.id) != skip_id && preset.name == base_name) - { + if !self.presets.iter().any(|preset| { + let Preset { + id, + name, + filters: _, + search_values: _, + } = preset; + Some(*id) != skip_id && name == base_name + }) { return Cow::Borrowed(base_name); } let mut suffix = 2; loop { let candidate = format!("{base_name}_{suffix}"); - if !self - .presets - .iter() - .any(|preset| Some(preset.id) != skip_id && preset.name == candidate) - { + if !self.presets.iter().any(|preset| { + let Preset { + id, + name, + filters: _, + search_values: _, + } = preset; + Some(*id) != skip_id && name == &candidate + }) { return Cow::Owned(candidate); } suffix += 1; } } + /// Returns a preset by id. pub fn get(&self, id: &Uuid) -> Option<&Preset> { self.presets.iter().find(|preset| preset.id == *id) } + /// Adds a new preset and returns its generated id. pub fn add_preset( &mut self, name: impl Into, - filters: Vec, - search_values: Vec, + filters: Vec, + search_values: Vec, ) -> Uuid { let name = name.into(); let name = self.unique_preset_name(&name, None).into_owned(); @@ -122,35 +129,13 @@ impl PresetRegistry { PresetImportSummary { renamed_items } } - pub fn add_preset_from_session( - &mut self, - shared: &SessionShared, - registry: &FilterRegistry, - ) -> Uuid { - let filters = shared - .filters - .filter_entries - .iter() - .filter_map(|item| registry.get_filter(&item.id)) - .map(|def| def.filter.clone()) - .collect(); - let search_values = shared - .filters - .search_value_entries - .iter() - .filter_map(|item| registry.get_search_value(&item.id)) - .map(|def| def.filter.clone()) - .collect(); - - self.add_preset(shared.get_info().title.clone(), filters, search_values) - } - + /// Updates an existing preset with the provided row snapshots. pub fn update_preset( &mut self, id: Uuid, requested_name: impl Into, - filters: Vec, - search_values: Vec, + filters: Vec, + search_values: Vec, ) -> PresetUpdateOutcome { let requested_name = requested_name.into(); let Some(index) = self.presets.iter().position(|preset| preset.id == id) else { @@ -158,15 +143,22 @@ impl PresetRegistry { }; let next_name = self.unique_preset_name(&requested_name, Some(id)); - let preset = &mut self.presets[index]; - if preset.name == next_name.as_ref() - && preset.filters == filters - && preset.search_values == search_values + let preset = &self.presets[index]; + let Preset { + id: _, + name, + filters: stored_filters, + search_values: stored_search_values, + } = preset; + if name == next_name.as_ref() + && stored_filters == &filters + && stored_search_values == &search_values { return PresetUpdateOutcome::Unchanged; } let next_name = next_name.into_owned(); + let preset = &mut self.presets[index]; preset.name = next_name.clone(); preset.filters = filters; preset.search_values = search_values; @@ -175,6 +167,7 @@ impl PresetRegistry { PresetUpdateOutcome::Updated { name: next_name } } + /// Removes a preset by id and reports whether anything was removed. pub fn remove_preset(&mut self, id: Uuid) -> bool { let Some(index) = self.presets.iter().position(|preset| preset.id == id) else { return false; @@ -190,13 +183,18 @@ impl PresetRegistry { mod tests { use std::path::PathBuf; + use processor::search::filter::SearchFilter; use stypes::{FileFormat, ObserveOrigin}; + use uuid::Uuid; use crate::{ host::{ common::parsers::ParserNames, - ui::registry::filters::{FilterDefinition, RegistryEditOutcome, SearchValueDefinition}, + ui::registry::filters::{ + FilterDefinition, FilterRegistry, RegistryEditOutcome, SearchValueDefinition, + }, }, + session::ui::SessionShared, session::{types::ObserveOperation, ui::SessionInfo}, }; @@ -211,6 +209,53 @@ mod tests { SearchFilter::plain(value).regex(true).ignore_case(true) } + fn runtime_preset( + id: Uuid, + name: &str, + filters: Vec, + search_values: Vec, + ) -> Preset { + Preset::with_default_state(id, name.to_owned(), filters, search_values) + } + + fn add_preset_with_default_state( + registry: &mut PresetRegistry, + name: &str, + filters: Vec, + search_values: Vec, + ) -> Uuid { + registry.add_preset( + name, + filter_entries(filters), + search_value_entries(search_values), + ) + } + + fn filter_entries(filters: Vec) -> Vec { + Preset::with_default_state(Uuid::new_v4(), "preset".to_owned(), filters, vec![]).filters + } + + fn search_value_entries(search_values: Vec) -> Vec { + Preset::with_default_state(Uuid::new_v4(), "preset".to_owned(), vec![], search_values) + .search_values + } + + fn filter_definitions(preset: &Preset) -> Vec { + preset + .filters + .iter() + .map(|entry| entry.filter.clone()) + .collect() + } + + fn search_value_definitions(preset: &Preset) -> Vec { + preset + .search_values + .iter() + .map(|entry| entry.filter.clone()) + .collect() + } + fn new_shared() -> SessionShared { let session_id = Uuid::new_v4(); let origin = ObserveOrigin::File( @@ -255,8 +300,10 @@ mod tests { #[test] fn add_keeps_order() { let mut registry = PresetRegistry::default(); - let first_id = registry.add_preset("First", vec![plain("one")], vec![]); - let second_id = registry.add_preset("Second", vec![plain("two")], vec![]); + let first_id = + add_preset_with_default_state(&mut registry, "First", vec![plain("one")], vec![]); + let second_id = + add_preset_with_default_state(&mut registry, "Second", vec![plain("two")], vec![]); assert_eq!(registry.presets()[0].id, first_id); assert_eq!(registry.presets()[1].id, second_id); @@ -266,14 +313,15 @@ mod tests { #[test] fn keeps_filter_duplicates() { let mut registry = PresetRegistry::default(); - let preset_id = registry.add_preset( + let preset_id = add_preset_with_default_state( + &mut registry, "Errors", vec![plain("error"), plain("warn"), plain("error"), plain("warn")], vec![], ); assert_eq!( - registry.get(&preset_id).unwrap().filters, + filter_definitions(registry.get(&preset_id).unwrap()), vec![plain("error"), plain("warn"), plain("error"), plain("warn")] ); } @@ -281,7 +329,8 @@ mod tests { #[test] fn keeps_search_value_duplicates() { let mut registry = PresetRegistry::default(); - let preset_id = registry.add_preset( + let preset_id = add_preset_with_default_state( + &mut registry, "Durations", vec![], vec![ @@ -292,7 +341,7 @@ mod tests { ); assert_eq!( - registry.get(&preset_id).unwrap().search_values, + search_value_definitions(registry.get(&preset_id).unwrap()), vec![ regex("duration=(\\d+)"), regex("latency=(\\d+)"), @@ -304,26 +353,35 @@ mod tests { #[test] fn duplicates_stay_per_list() { let mut registry = PresetRegistry::default(); - let preset_id = registry.add_preset( + let preset_id = add_preset_with_default_state( + &mut registry, "Shared Value", vec![plain("error"), plain("error")], vec![plain("error"), plain("error")], ); let preset = registry.get(&preset_id).unwrap(); - assert_eq!(preset.filters, vec![plain("error"), plain("error")]); - assert_eq!(preset.search_values, vec![plain("error"), plain("error")]); + assert_eq!( + filter_definitions(preset), + vec![plain("error"), plain("error")] + ); + assert_eq!( + search_value_definitions(preset), + vec![plain("error"), plain("error")] + ); } #[test] fn identical_presets_both_store() { let mut registry = PresetRegistry::default(); - let first_id = registry.add_preset( + let first_id = add_preset_with_default_state( + &mut registry, "Errors", vec![plain("error")], vec![regex("duration=(\\d+)")], ); - let second_id = registry.add_preset( + let second_id = add_preset_with_default_state( + &mut registry, "Errors", vec![plain("error")], vec![regex("duration=(\\d+)")], @@ -336,9 +394,10 @@ mod tests { #[test] fn keeps_base_name_free() { let mut registry = PresetRegistry::default(); - registry.add_preset("Errors_2", vec![plain("error")], vec![]); + add_preset_with_default_state(&mut registry, "Errors_2", vec![plain("error")], vec![]); - let preset_id = registry.add_preset("Errors", vec![plain("warn")], vec![]); + let preset_id = + add_preset_with_default_state(&mut registry, "Errors", vec![plain("warn")], vec![]); assert_eq!(registry.get(&preset_id).unwrap().name, "Errors"); } @@ -346,9 +405,10 @@ mod tests { #[test] fn appends_second_suffix() { let mut registry = PresetRegistry::default(); - registry.add_preset("Errors", vec![plain("error")], vec![]); + add_preset_with_default_state(&mut registry, "Errors", vec![plain("error")], vec![]); - let preset_id = registry.add_preset("Errors", vec![plain("warn")], vec![]); + let preset_id = + add_preset_with_default_state(&mut registry, "Errors", vec![plain("warn")], vec![]); assert_eq!(registry.get(&preset_id).unwrap().name, "Errors_2"); } @@ -356,12 +416,7 @@ mod tests { #[test] fn import_keeps_provided_id() { let mut registry = PresetRegistry::default(); - let preset = Preset { - id: Uuid::new_v4(), - name: "Imported".to_owned(), - filters: vec![plain("error")], - search_values: vec![], - }; + let preset = runtime_preset(Uuid::new_v4(), "Imported", vec![plain("error")], vec![]); let imported_id = preset.id; let was_renamed = registry.import_preset(preset); @@ -373,15 +428,15 @@ mod tests { #[test] fn import_renames_colliding_name() { let mut registry = PresetRegistry::default(); - registry.add_preset("Errors", vec![plain("warn")], vec![]); + add_preset_with_default_state(&mut registry, "Errors", vec![plain("warn")], vec![]); let imported_id = Uuid::new_v4(); - let was_renamed = registry.import_preset(Preset { - id: imported_id, - name: "Errors".to_owned(), - filters: vec![plain("error")], - search_values: vec![], - }); + let was_renamed = registry.import_preset(runtime_preset( + imported_id, + "Errors", + vec![plain("error")], + vec![], + )); assert!(was_renamed); assert_eq!(registry.get(&imported_id).unwrap().name, "Errors_2"); @@ -391,24 +446,9 @@ mod tests { fn import_batch_preserves_order() { let mut registry = PresetRegistry::default(); let presets = vec![ - Preset { - id: Uuid::new_v4(), - name: "Same".to_owned(), - filters: vec![plain("one")], - search_values: vec![], - }, - Preset { - id: Uuid::new_v4(), - name: "Same".to_owned(), - filters: vec![plain("two")], - search_values: vec![], - }, - Preset { - id: Uuid::new_v4(), - name: "Third".to_owned(), - filters: vec![plain("three")], - search_values: vec![], - }, + runtime_preset(Uuid::new_v4(), "Same", vec![plain("one")], vec![]), + runtime_preset(Uuid::new_v4(), "Same", vec![plain("two")], vec![]), + runtime_preset(Uuid::new_v4(), "Third", vec![plain("three")], vec![]), ]; let summary = registry.import_presets(presets); @@ -424,18 +464,8 @@ mod tests { let mut registry = PresetRegistry::default(); registry.import_presets(vec![ - Preset { - id: Uuid::new_v4(), - name: "First".to_owned(), - filters: vec![plain("one")], - search_values: vec![], - }, - Preset { - id: Uuid::new_v4(), - name: "Second".to_owned(), - filters: vec![plain("two")], - search_values: vec![], - }, + runtime_preset(Uuid::new_v4(), "First", vec![plain("one")], vec![]), + runtime_preset(Uuid::new_v4(), "Second", vec![plain("two")], vec![]), ]); assert_eq!(registry.definitions_revision(), 2); @@ -444,10 +474,11 @@ mod tests { #[test] fn skips_taken_suffixes() { let mut registry = PresetRegistry::default(); - registry.add_preset("Errors", vec![plain("error")], vec![]); - registry.add_preset("Errors_2", vec![plain("warn")], vec![]); + add_preset_with_default_state(&mut registry, "Errors", vec![plain("error")], vec![]); + add_preset_with_default_state(&mut registry, "Errors_2", vec![plain("warn")], vec![]); - let preset_id = registry.add_preset("Errors", vec![plain("info")], vec![]); + let preset_id = + add_preset_with_default_state(&mut registry, "Errors", vec![plain("info")], vec![]); assert_eq!(registry.get(&preset_id).unwrap().name, "Errors_3"); } @@ -455,9 +486,12 @@ mod tests { #[test] fn remove_preset_keeps_other_order() { let mut registry = PresetRegistry::default(); - let first_id = registry.add_preset("First", vec![plain("one")], vec![]); - let second_id = registry.add_preset("Second", vec![plain("two")], vec![]); - let third_id = registry.add_preset("Third", vec![plain("three")], vec![]); + let first_id = + add_preset_with_default_state(&mut registry, "First", vec![plain("one")], vec![]); + let second_id = + add_preset_with_default_state(&mut registry, "Second", vec![plain("two")], vec![]); + let third_id = + add_preset_with_default_state(&mut registry, "Third", vec![plain("three")], vec![]); assert!(registry.remove_preset(second_id)); @@ -480,7 +514,8 @@ mod tests { let filter_id = add_filter_definition(&mut filters_registry, "error"); filters_registry.apply_filter_to_session(filter_id, session_id); - let preset_id = preset_registry.add_preset( + let preset_id = add_preset_with_default_state( + &mut preset_registry, "Errors", vec![ filters_registry @@ -504,7 +539,7 @@ mod tests { plain("warn") ); assert_eq!( - preset_registry.get(&preset_id).unwrap().filters, + filter_definitions(preset_registry.get(&preset_id).unwrap()), vec![plain("error")] ); } @@ -518,7 +553,8 @@ mod tests { let value_id = add_search_value_definition(&mut filters_registry, "duration=(\\d+)"); filters_registry.apply_search_value_to_session(value_id, session_id); - let preset_id = preset_registry.add_preset( + let preset_id = add_preset_with_default_state( + &mut preset_registry, "Durations", vec![], vec![ @@ -544,7 +580,7 @@ mod tests { regex("latency=(\\d+)") ); assert_eq!( - preset_registry.get(&preset_id).unwrap().search_values, + search_value_definitions(preset_registry.get(&preset_id).unwrap()), vec![regex("duration=(\\d+)")] ); } @@ -552,7 +588,8 @@ mod tests { #[test] fn update_preset_replaces_all_fields() { let mut registry = PresetRegistry::default(); - let preset_id = registry.add_preset( + let preset_id = add_preset_with_default_state( + &mut registry, "Errors", vec![plain("error")], vec![regex("duration=(\\d+)")], @@ -561,8 +598,8 @@ mod tests { let outcome = registry.update_preset( preset_id, "Warnings", - vec![plain("warn"), plain("info")], - vec![regex("latency=(\\d+)")], + filter_entries(vec![plain("warn"), plain("info")]), + search_value_entries(vec![regex("latency=(\\d+)")]), ); assert_eq!( @@ -573,12 +610,12 @@ mod tests { ); assert_eq!( registry.get(&preset_id).unwrap(), - &Preset { - id: preset_id, - name: "Warnings".to_owned(), - filters: vec![plain("warn"), plain("info")], - search_values: vec![regex("latency=(\\d+)")], - } + &runtime_preset( + preset_id, + "Warnings", + vec![plain("warn"), plain("info")], + vec![regex("latency=(\\d+)")], + ) ); assert_eq!(registry.definitions_revision(), 2); } @@ -586,7 +623,8 @@ mod tests { #[test] fn update_preset_keeps_same_data() { let mut registry = PresetRegistry::default(); - let preset_id = registry.add_preset( + let preset_id = add_preset_with_default_state( + &mut registry, "Errors", vec![plain("error")], vec![regex("duration=(\\d+)")], @@ -595,8 +633,8 @@ mod tests { let outcome = registry.update_preset( preset_id, "Errors", - vec![plain("error")], - vec![regex("duration=(\\d+)")], + filter_entries(vec![plain("error")]), + search_value_entries(vec![regex("duration=(\\d+)")]), ); assert_eq!(outcome, PresetUpdateOutcome::Unchanged); @@ -607,11 +645,17 @@ mod tests { #[test] fn update_preset_uses_unique_name() { let mut registry = PresetRegistry::default(); - let first_id = registry.add_preset("First", vec![plain("one")], vec![]); - registry.add_preset("Taken", vec![plain("two")], vec![]); - registry.add_preset("Taken_2", vec![plain("three")], vec![]); + let first_id = + add_preset_with_default_state(&mut registry, "First", vec![plain("one")], vec![]); + add_preset_with_default_state(&mut registry, "Taken", vec![plain("two")], vec![]); + add_preset_with_default_state(&mut registry, "Taken_2", vec![plain("three")], vec![]); - let outcome = registry.update_preset(first_id, "Taken", vec![plain("one")], vec![]); + let outcome = registry.update_preset( + first_id, + "Taken", + filter_entries(vec![plain("one")]), + vec![], + ); assert_eq!( outcome, @@ -630,8 +674,8 @@ mod tests { let outcome = registry.update_preset( Uuid::new_v4(), "Missing", - vec![plain("one")], - vec![regex("duration=(\\d+)")], + filter_entries(vec![plain("one")]), + search_value_entries(vec![regex("duration=(\\d+)")]), ); assert_eq!(outcome, PresetUpdateOutcome::NotFound); @@ -641,68 +685,10 @@ mod tests { #[test] fn remove_preset_advances_revision() { let mut registry = PresetRegistry::default(); - let preset_id = registry.add_preset("Errors", vec![plain("one")], vec![]); + let preset_id = + add_preset_with_default_state(&mut registry, "Errors", vec![plain("one")], vec![]); assert!(registry.remove_preset(preset_id)); assert_eq!(registry.definitions_revision(), 2); } - - #[test] - fn captures_applied_session_state() { - let mut shared = new_shared(); - let mut filters_registry = FilterRegistry::default(); - let mut preset_registry = PresetRegistry::default(); - let first_filter_id = add_filter_definition(&mut filters_registry, "error"); - let second_filter_id = add_filter_definition(&mut filters_registry, "warn"); - let first_value_id = add_search_value_definition(&mut filters_registry, "duration=(\\d+)"); - let second_value_id = add_search_value_definition(&mut filters_registry, "latency=(\\d+)"); - - shared - .filters - .apply_filter(&mut filters_registry, first_filter_id); - shared - .filters - .apply_filter_with_state(&mut filters_registry, second_filter_id, false); - shared - .filters - .apply_search_value(&mut filters_registry, first_value_id); - shared - .filters - .apply_search_value_with_state(&mut filters_registry, second_value_id, false); - - let preset_id = preset_registry.add_preset_from_session(&shared, &filters_registry); - let preset = preset_registry.get(&preset_id).unwrap(); - - assert_eq!(preset.name, "test"); - assert_eq!( - preset.filters, - vec![ - filters_registry - .get_filter(&first_filter_id) - .unwrap() - .filter - .clone(), - filters_registry - .get_filter(&second_filter_id) - .unwrap() - .filter - .clone(), - ] - ); - assert_eq!( - preset.search_values, - vec![ - filters_registry - .get_search_value(&first_value_id) - .unwrap() - .filter - .clone(), - filters_registry - .get_search_value(&second_value_id) - .unwrap() - .filter - .clone(), - ] - ); - } } diff --git a/crates/app/src/host/ui/registry/presets/mod.rs b/crates/app/src/host/ui/registry/presets/mod.rs new file mode 100644 index 0000000000..bd8d73372f --- /dev/null +++ b/crates/app/src/host/ui/registry/presets/mod.rs @@ -0,0 +1,8 @@ +//! Host registry storage for named preset snapshots. + +mod capture; +mod catalog; +mod model; + +pub use catalog::{PresetRegistry, PresetUpdateOutcome}; +pub use model::{Preset, PresetFilterEntry, PresetSearchValueEntry}; diff --git a/crates/app/src/host/ui/registry/presets/model.rs b/crates/app/src/host/ui/registry/presets/model.rs new file mode 100644 index 0000000000..ed1cc023eb --- /dev/null +++ b/crates/app/src/host/ui/registry/presets/model.rs @@ -0,0 +1,120 @@ +//! Preset data model and row snapshot constructors. + +use egui::Color32; +use processor::search::filter::SearchFilter; +use uuid::Uuid; + +use crate::host::common::colors::{self, ColorPair}; + +/// Preset definition with copied row state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Preset { + /// Runtime identifier used by UI selection and edit flows. + pub id: Uuid, + /// User-visible preset name. + pub name: String, + /// Stored filter rows. + pub filters: Vec, + /// Stored chart/search-value rows. + pub search_values: Vec, +} + +/// Stored preset snapshot for one filter row. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PresetFilterEntry { + /// Filter definition stored for the row. + pub filter: SearchFilter, + /// Whether the row was enabled when captured. + pub enabled: bool, + /// Highlight colors stored for the row. + pub colors: ColorPair, +} + +/// Stored preset snapshot for one chart/search-value row. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PresetSearchValueEntry { + /// Search-value definition stored for the row. + pub filter: SearchFilter, + /// Whether the row was enabled when captured. + pub enabled: bool, + /// Chart color stored for the row. + pub color: Color32, +} + +impl PresetFilterEntry { + /// Creates a filter entry with explicit row state. + pub fn new(filter: SearchFilter, enabled: bool, colors: ColorPair) -> Self { + Self { + filter, + enabled, + colors, + } + } + + /// Creates an enabled filter entry using the default color for its row index. + pub fn with_default_color(filter: SearchFilter, index: usize) -> Self { + let colors = + colors::FILTER_HIGHLIGHT_COLORS[index % colors::FILTER_HIGHLIGHT_COLORS.len()].clone(); + Self::new(filter, true, colors) + } + + /// Creates an enabled filter entry using the next color after existing entries. + pub fn with_next_color(filter: SearchFilter, entries: &[Self]) -> Self { + let used_colors = entries + .iter() + .map(|entry| entry.colors.clone()) + .collect::>(); + + Self::new(filter, true, colors::next_filter_color(&used_colors)) + } +} + +impl PresetSearchValueEntry { + /// Creates a search-value entry with explicit row state. + pub fn new(filter: SearchFilter, enabled: bool, color: Color32) -> Self { + Self { + filter, + enabled, + color, + } + } + + /// Creates an enabled search-value entry using the default color for its row index. + pub fn with_default_color(filter: SearchFilter, index: usize) -> Self { + Self::new(filter, true, colors::search_value_color(index)) + } + + /// Creates an enabled search-value entry using the next color after existing entries. + pub fn with_next_color(filter: SearchFilter, entries: &[Self]) -> Self { + let used_colors = entries.iter().map(|entry| entry.color).collect::>(); + Self::new(filter, true, colors::next_search_value_color(&used_colors)) + } +} + +impl Preset { + /// Builds a preset from filters and default row state. + pub fn with_default_state( + id: Uuid, + name: String, + filters: Vec, + search_values: Vec, + ) -> Self { + let filters = filters + .into_iter() + .enumerate() + .map(|(index, filter)| PresetFilterEntry::with_default_color(filter, index)) + .collect(); + let search_values = search_values + .into_iter() + .enumerate() + .map(|(index, filter)| PresetSearchValueEntry::with_default_color(filter, index)) + .collect(); + + Self { + id, + name, + filters, + search_values, + } + } +} diff --git a/crates/app/src/host/ui/state/presets.rs b/crates/app/src/host/ui/state/presets.rs index 8f4e649932..18a80ebd6c 100644 --- a/crates/app/src/host/ui/state/presets.rs +++ b/crates/app/src/host/ui/state/presets.rs @@ -1,13 +1,21 @@ +//! Host UI handlers for preset import and export messages. + use std::{ + borrow::Cow, fmt::Write, path::{Path, PathBuf}, }; -use crate::host::{message::PresetsImported, notification::AppNotification, ui::UiActions}; +use crate::host::{ + message::{ImportFormat, PresetsImported}, + notification::AppNotification, + ui::UiActions, +}; use super::HostState; impl HostState { + /// Imports backend-loaded presets into the registry and reports the result. pub fn handle_presets_imported( &mut self, imported: PresetsImported, @@ -16,7 +24,7 @@ impl HostState { let PresetsImported { path, presets, - used_legacy_format, + format, } = imported; let imported_count = presets.len(); @@ -24,11 +32,12 @@ impl HostState { ui_actions.add_notification(AppNotification::Info(format_preset_import_report( &path, imported_count, - used_legacy_format, + format, summary.renamed_items, ))); } + /// Reports a completed preset export. pub fn handle_presets_exported(&self, path: PathBuf, count: usize, ui_actions: &mut UiActions) { ui_actions.add_notification(AppNotification::Info(format_preset_export_report( &path, count, @@ -39,27 +48,16 @@ impl HostState { fn format_preset_import_report( path: &Path, imported_count: usize, - used_legacy_format: bool, + format: ImportFormat, renamed_count: usize, ) -> String { - let source = if used_legacy_format { - "legacy preset file" - } else { - "preset file" - }; - + let file_label = file_label(path); let mut message = if imported_count == 0 { - format!( - "No presets were imported from {} '{}'.", - source, - path.display() - ) + format!("No presets were imported from '{file_label}'.") } else { format!( - "Imported {imported_count} {} from {} '{}'.", - pluralize(imported_count, "preset", "presets"), - source, - path.display() + "Imported {imported_count} {} from '{file_label}'.", + pluralize(imported_count, "preset", "presets") ) }; @@ -67,66 +65,38 @@ fn format_preset_import_report( // writing to String should never fail let _ = write!( message, - " Renamed {renamed_count} {} to avoid name collisions.", + "\nRenamed {renamed_count} {} to avoid duplicate names.", pluralize(renamed_count, "preset", "presets") ); } + match format { + ImportFormat::Version1 => { + message.push_str( + "\nThis older preset file does not include all current preset settings. \ + Export these presets again to preserve the complete settings for future imports.", + ); + } + ImportFormat::Version2 | ImportFormat::Legacy => {} + } + message } fn format_preset_export_report(path: &Path, count: usize) -> String { + let file_label = file_label(path); format!( - "Exported {count} {} to '{}'.", - pluralize(count, "preset", "presets"), - path.display() + "Exported {count} {} to '{file_label}'.", + pluralize(count, "preset", "presets") ) } -fn pluralize<'a>(count: usize, singular: &'a str, plural: &'a str) -> &'a str { - if count == 1 { singular } else { plural } +fn file_label(path: &Path) -> Cow<'_, str> { + path.file_name() + .map(|file_name| file_name.to_string_lossy()) + .unwrap_or_else(|| Cow::Owned(path.display().to_string())) } -#[cfg(test)] -mod tests { - use std::path::Path; - - use super::{format_preset_export_report, format_preset_import_report}; - - #[test] - fn import_report_for_document_file() { - let message = format_preset_import_report(Path::new("presets.json"), 2, false, 0); - - assert_eq!( - message, - "Imported 2 presets from preset file 'presets.json'." - ); - } - - #[test] - fn import_report_for_legacy_file_with_renames() { - let message = format_preset_import_report(Path::new("legacy.json"), 3, true, 2); - - assert_eq!( - message, - "Imported 3 presets from legacy preset file 'legacy.json'. Renamed 2 presets to avoid name collisions." - ); - } - - #[test] - fn import_report_for_empty_import() { - let message = format_preset_import_report(Path::new("legacy.json"), 0, true, 0); - - assert_eq!( - message, - "No presets were imported from legacy preset file 'legacy.json'." - ); - } - - #[test] - fn export_report_for_multiple_presets() { - let message = format_preset_export_report(Path::new("presets.json"), 2); - - assert_eq!(message, "Exported 2 presets to 'presets.json'."); - } +fn pluralize<'a>(count: usize, singular: &'a str, plural: &'a str) -> &'a str { + if count == 1 { singular } else { plural } } diff --git a/crates/app/src/session/ui/bottom_panel/presets/apply.rs b/crates/app/src/session/ui/bottom_panel/presets/apply.rs new file mode 100644 index 0000000000..c80ff22f33 --- /dev/null +++ b/crates/app/src/session/ui/bottom_panel/presets/apply.rs @@ -0,0 +1,585 @@ +//! Preset application into the active session state. + +use uuid::Uuid; + +use crate::{ + host::ui::{ + UiActions, + registry::{ + HostRegistry, + filters::{FilterDefinition, FilterRegistry, SearchValueDefinition}, + presets::{PresetFilterEntry, PresetSearchValueEntry}, + }, + }, + session::ui::shared::{SearchSyncTarget, SessionShared}, +}; + +use super::PresetsUI; + +/// Result of applying a preset into the current session state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PresetApplyOutcome { + /// No preset exists for the requested id. + NotFound, + /// The preset did not add or update session rows. + NoChanges, + /// The preset changed session rows, but active backend search inputs stayed unchanged. + AppliedNoSync, + /// The preset changed rows that require backend sync. + Applied(SearchSyncTarget), +} + +impl PresetsUI { + /// Applies a preset to the session and dispatches required backend sync commands. + pub fn apply_preset( + &self, + shared: &mut SessionShared, + actions: &mut UiActions, + registry: &mut HostRegistry, + preset_id: Uuid, + ) -> PresetApplyOutcome { + let Some((filters, search_values)) = registry + .presets + .get(&preset_id) + .map(|preset| (preset.filters.clone(), preset.search_values.clone())) + else { + return PresetApplyOutcome::NotFound; + }; + + let before_filters = shared + .filters + .enabled_filter_ids() + .copied() + .collect::>(); + let before_search_values = shared + .filters + .enabled_search_value_ids() + .copied() + .collect::>(); + + let mut changed = false; + for entry in filters { + let PresetFilterEntry { + filter, + enabled, + colors, + } = entry; + let filter_id = registry.filters.add_filter(FilterDefinition::new(filter)); + changed |= + shared.set_filter_entry_state(&mut registry.filters, filter_id, enabled, colors); + } + + for entry in search_values { + let PresetSearchValueEntry { + filter, + enabled, + color, + } = entry; + let value_id = registry + .filters + .add_search_value(SearchValueDefinition::new(filter)); + changed |= shared.set_search_value_entry_state( + &mut registry.filters, + value_id, + enabled, + color, + ); + } + + let after_filters = shared + .filters + .enabled_filter_ids() + .copied() + .collect::>(); + let after_search_values = shared + .filters + .enabled_search_value_ids() + .copied() + .collect::>(); + + let filters_changed = before_filters != after_filters; + let search_values_changed = before_search_values != after_search_values; + let outcome = match (filters_changed, search_values_changed) { + (false, false) if changed => PresetApplyOutcome::AppliedNoSync, + (false, false) => PresetApplyOutcome::NoChanges, + (true, false) => PresetApplyOutcome::Applied(SearchSyncTarget::Filter), + (false, true) => PresetApplyOutcome::Applied(SearchSyncTarget::SearchValue), + (true, true) => PresetApplyOutcome::Applied(SearchSyncTarget::Both), + }; + + if let PresetApplyOutcome::Applied(target) = outcome { + self.dispatch_sync_commands(shared, actions, ®istry.filters, target); + } + + outcome + } + + /// Sends backend sync commands for changed preset-applied search state. + pub fn dispatch_sync_commands( + &self, + shared: &mut SessionShared, + actions: &mut UiActions, + registry: &FilterRegistry, + target: SearchSyncTarget, + ) { + // Preset apply mutates session state first, then issues the same explicit + // sync commands used by the rest of the search/filter UI. + shared + .sync_search(registry, target) + .into_iter() + .for_each(|cmd| _ = actions.try_send_command(&self.cmd_tx, cmd)); + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use egui::Color32; + use processor::search::filter::SearchFilter; + use stypes::{FileFormat, ObserveOrigin}; + use tokio::{runtime::Runtime, sync::mpsc}; + use uuid::Uuid; + + use crate::{ + host::{ + command::HostCommand, + common::{colors::ColorPair, parsers::ParserNames}, + ui::{ + UiActions, + registry::{ + HostRegistry, + filters::{FilterDefinition, FilterRegistry, SearchValueDefinition}, + }, + }, + }, + session::{command::SessionCommand, types::ObserveOperation, ui::SessionInfo}, + }; + + use super::*; + use crate::session::ui::definitions::schema::LogSchemaSpec; + + fn new_presets() -> ( + PresetsUI, + mpsc::Receiver, + mpsc::Receiver, + ) { + let (cmd_tx, cmd_rx) = mpsc::channel(8); + let (host_cmd_tx, host_cmd_rx) = mpsc::channel(8); + (PresetsUI::new(cmd_tx, host_cmd_tx), cmd_rx, host_cmd_rx) + } + + fn new_shared() -> SessionShared { + let session_id = Uuid::new_v4(); + let origin = ObserveOrigin::File( + "source".to_owned(), + FileFormat::Text, + PathBuf::from("source.log"), + ); + let observe_op = ObserveOperation::new(Uuid::new_v4(), origin); + let session_info = SessionInfo { + id: session_id, + title: "test".to_owned(), + parser: ParserNames::Text, + raw_export_supported: false, + }; + + SessionShared::new(session_info, observe_op, LogSchemaSpec::Text) + } + + fn new_actions(runtime: &Runtime) -> UiActions { + UiActions::new(runtime.handle().clone()) + } + + fn add_filter_definition(registry: &mut FilterRegistry, value: &str) -> Uuid { + let definition = FilterDefinition::new(SearchFilter::plain(value).ignore_case(true)); + let id = definition.id; + registry.add_filter(definition); + id + } + + fn add_search_value_definition(registry: &mut FilterRegistry, value: &str) -> Uuid { + let definition = + SearchValueDefinition::new(SearchFilter::plain(value).regex(true).ignore_case(true)); + let id = definition.id; + registry.add_search_value(definition); + id + } + + fn drain_commands(cmd_rx: &mut mpsc::Receiver) -> Vec { + let mut commands = Vec::new(); + while let Ok(command) = cmd_rx.try_recv() { + commands.push(command); + } + commands + } + + fn add_preset_with_entries( + registry: &mut HostRegistry, + name: &str, + filters: Vec, + search_values: Vec, + ) -> Uuid { + registry.presets.add_preset(name, filters, search_values) + } + + fn filter_entry(value: &str, enabled: bool, colors: ColorPair) -> PresetFilterEntry { + let filter = SearchFilter::plain(value).ignore_case(true); + PresetFilterEntry::new(filter, enabled, colors) + } + + fn search_value_entry(value: &str, enabled: bool, color: Color32) -> PresetSearchValueEntry { + let filter = SearchFilter::plain(value).regex(true).ignore_case(true); + PresetSearchValueEntry::new(filter, enabled, color) + } + + fn filter_colors(fg: u8, bg: u8) -> ColorPair { + ColorPair::new(Color32::from_gray(fg), Color32::from_gray(bg)) + } + + fn applied_filter_values(shared: &SessionShared, registry: &HostRegistry) -> Vec { + shared + .filters + .filter_entries + .iter() + .map(|item| { + registry + .filters + .get_filter(&item.id) + .unwrap() + .filter + .value + .clone() + }) + .collect() + } + + fn applied_search_value_values(shared: &SessionShared, registry: &HostRegistry) -> Vec { + shared + .filters + .search_value_entries + .iter() + .map(|item| { + registry + .filters + .get_search_value(&item.id) + .unwrap() + .filter + .value + .clone() + }) + .collect() + } + + #[test] + fn apply_preset_overwrites_existing_rows_and_syncs() { + let runtime = Runtime::new().unwrap(); + let (presets, mut cmd_rx, _) = new_presets(); + let mut shared = new_shared(); + let mut actions = new_actions(&runtime); + let mut registry = HostRegistry::default(); + let filter_id = add_filter_definition(&mut registry.filters, "error"); + let value_id = add_search_value_definition(&mut registry.filters, "duration=(\\d+)"); + let filter_colors = filter_colors(10, 20); + let search_value_color = Color32::from_rgb(30, 40, 50); + let preset_id = add_preset_with_entries( + &mut registry, + "test", + vec![filter_entry("error", true, filter_colors.clone())], + vec![search_value_entry( + "duration=(\\d+)", + true, + search_value_color, + )], + ); + + shared.apply_filter_with_state(&mut registry.filters, filter_id, false); + shared.apply_search_value_with_state(&mut registry.filters, value_id, false); + + let outcome = presets.apply_preset(&mut shared, &mut actions, &mut registry, preset_id); + + assert_eq!(outcome, PresetApplyOutcome::Applied(SearchSyncTarget::Both)); + assert_eq!(shared.filters.filter_entries.len(), 1); + assert_eq!(shared.filters.search_value_entries.len(), 1); + assert!(shared.filters.filter_entries[0].enabled); + assert!(shared.filters.search_value_entries[0].enabled); + assert_eq!(shared.filters.filter_entries[0].colors, filter_colors); + assert_eq!( + shared.filters.search_value_entries[0].color, + search_value_color + ); + assert_eq!(registry.filters.filters_map().len(), 1); + assert_eq!(registry.filters.search_value_map().len(), 1); + + let commands = drain_commands(&mut cmd_rx); + assert_eq!(commands.len(), 2); + } + + #[test] + fn apply_preset_adds_missing_rows_with_preset_state() { + let runtime = Runtime::new().unwrap(); + let (presets, mut cmd_rx, _) = new_presets(); + let mut shared = new_shared(); + let mut actions = new_actions(&runtime); + let mut registry = HostRegistry::default(); + let existing_filter_id = add_filter_definition(&mut registry.filters, "existing"); + let existing_value_id = + add_search_value_definition(&mut registry.filters, "existing=(\\d+)"); + let existing_filter_colors = filter_colors(1, 2); + let existing_value_color = Color32::from_rgb(1, 2, 3); + let added_filter_colors = filter_colors(3, 4); + let added_value_color = Color32::from_rgb(4, 5, 6); + let preset_id = add_preset_with_entries( + &mut registry, + "test", + vec![filter_entry("error", true, added_filter_colors.clone())], + vec![search_value_entry( + "duration=(\\d+)", + false, + added_value_color, + )], + ); + + shared.set_filter_entry_state( + &mut registry.filters, + existing_filter_id, + true, + existing_filter_colors.clone(), + ); + shared.set_search_value_entry_state( + &mut registry.filters, + existing_value_id, + true, + existing_value_color, + ); + + let outcome = presets.apply_preset(&mut shared, &mut actions, &mut registry, preset_id); + + assert_eq!( + outcome, + PresetApplyOutcome::Applied(SearchSyncTarget::Filter) + ); + assert_eq!( + applied_filter_values(&shared, ®istry), + vec!["existing".to_owned(), "error".to_owned()] + ); + assert_eq!( + applied_search_value_values(&shared, ®istry), + vec!["existing=(\\d+)".to_owned(), "duration=(\\d+)".to_owned()] + ); + assert_eq!( + shared.filters.filter_entries[0].colors, + existing_filter_colors + ); + assert_eq!( + shared.filters.search_value_entries[0].color, + existing_value_color + ); + assert_eq!(shared.filters.filter_entries[1].colors, added_filter_colors); + assert!(!shared.filters.search_value_entries[1].enabled); + assert_eq!( + shared.filters.search_value_entries[1].color, + added_value_color + ); + + let commands = drain_commands(&mut cmd_rx); + assert_eq!(commands.len(), 1); + match &commands[0] { + SessionCommand::ApplySearchFilter { filters, .. } => { + assert_eq!(filters.len(), 2); + assert_eq!(filters[0].value, "existing"); + assert_eq!(filters[1].value, "error"); + } + other => panic!("expected ApplySearchFilter command, got {other:?}"), + } + } + + #[test] + fn apply_preset_adds_disabled_rows_without_sync() { + let runtime = Runtime::new().unwrap(); + let (presets, mut cmd_rx, _) = new_presets(); + let mut shared = new_shared(); + let mut actions = new_actions(&runtime); + let mut registry = HostRegistry::default(); + let filter_colors = filter_colors(10, 20); + let search_value_color = Color32::from_rgb(30, 40, 50); + let preset_id = add_preset_with_entries( + &mut registry, + "test", + vec![filter_entry("error", false, filter_colors.clone())], + vec![search_value_entry( + "duration=(\\d+)", + false, + search_value_color, + )], + ); + + let revision = shared.recent_revision(); + + let outcome = presets.apply_preset(&mut shared, &mut actions, &mut registry, preset_id); + + assert_eq!(outcome, PresetApplyOutcome::AppliedNoSync); + assert!(shared.recent_revision() > revision); + assert_eq!(shared.filters.filter_entries.len(), 1); + assert_eq!(shared.filters.search_value_entries.len(), 1); + assert!(!shared.filters.filter_entries[0].enabled); + assert!(!shared.filters.search_value_entries[0].enabled); + assert_eq!(shared.filters.filter_entries[0].colors, filter_colors); + assert_eq!( + shared.filters.search_value_entries[0].color, + search_value_color + ); + assert!(drain_commands(&mut cmd_rx).is_empty()); + } + + #[test] + fn apply_preset_color_only_changes_do_not_sync() { + let runtime = Runtime::new().unwrap(); + let (presets, mut cmd_rx, _) = new_presets(); + let mut shared = new_shared(); + let mut actions = new_actions(&runtime); + let mut registry = HostRegistry::default(); + let filter_id = add_filter_definition(&mut registry.filters, "error"); + let value_id = add_search_value_definition(&mut registry.filters, "duration=(\\d+)"); + let filter_colors = filter_colors(70, 80); + let search_value_color = Color32::from_rgb(90, 100, 110); + let preset_id = add_preset_with_entries( + &mut registry, + "test", + vec![filter_entry("error", true, filter_colors.clone())], + vec![search_value_entry( + "duration=(\\d+)", + true, + search_value_color, + )], + ); + + shared.apply_filter(&mut registry.filters, filter_id); + shared.apply_search_value(&mut registry.filters, value_id); + let revision = shared.recent_revision(); + + let outcome = presets.apply_preset(&mut shared, &mut actions, &mut registry, preset_id); + + assert_eq!(outcome, PresetApplyOutcome::AppliedNoSync); + assert!(shared.recent_revision() > revision); + assert!(shared.filters.filter_entries[0].enabled); + assert!(shared.filters.search_value_entries[0].enabled); + assert_eq!(shared.filters.filter_entries[0].colors, filter_colors); + assert_eq!( + shared.filters.search_value_entries[0].color, + search_value_color + ); + assert!(drain_commands(&mut cmd_rx).is_empty()); + } + + #[test] + fn apply_preset_returns_no_changes_for_identical_state() { + let runtime = Runtime::new().unwrap(); + let (presets, mut cmd_rx, _) = new_presets(); + let mut shared = new_shared(); + let mut actions = new_actions(&runtime); + let mut registry = HostRegistry::default(); + let filter_id = add_filter_definition(&mut registry.filters, "error"); + let value_id = add_search_value_definition(&mut registry.filters, "duration=(\\d+)"); + let filter_colors = filter_colors(10, 20); + let search_value_color = Color32::from_rgb(30, 40, 50); + let preset_id = add_preset_with_entries( + &mut registry, + "test", + vec![filter_entry("error", true, filter_colors.clone())], + vec![search_value_entry( + "duration=(\\d+)", + true, + search_value_color, + )], + ); + + shared.set_filter_entry_state(&mut registry.filters, filter_id, true, filter_colors); + shared.set_search_value_entry_state( + &mut registry.filters, + value_id, + true, + search_value_color, + ); + let revision = shared.recent_revision(); + + let outcome = presets.apply_preset(&mut shared, &mut actions, &mut registry, preset_id); + + assert_eq!(outcome, PresetApplyOutcome::NoChanges); + assert_eq!(shared.recent_revision(), revision); + assert!(drain_commands(&mut cmd_rx).is_empty()); + } + + #[test] + fn apply_preset_duplicates_keep_first_position_and_last_state() { + let runtime = Runtime::new().unwrap(); + let (presets, mut cmd_rx, _) = new_presets(); + let mut shared = new_shared(); + let mut actions = new_actions(&runtime); + let mut registry = HostRegistry::default(); + let first_colors = filter_colors(1, 2); + let last_colors = filter_colors(3, 4); + let other_colors = filter_colors(5, 6); + let first_value_color = Color32::from_rgb(1, 2, 3); + let last_value_color = Color32::from_rgb(4, 5, 6); + let other_value_color = Color32::from_rgb(7, 8, 9); + let preset_id = add_preset_with_entries( + &mut registry, + "test", + vec![ + filter_entry("dup", true, first_colors), + filter_entry("other", false, other_colors.clone()), + filter_entry("dup", false, last_colors.clone()), + ], + vec![ + search_value_entry("dup=(\\d+)", true, first_value_color), + search_value_entry("other=(\\d+)", false, other_value_color), + search_value_entry("dup=(\\d+)", false, last_value_color), + ], + ); + + let outcome = presets.apply_preset(&mut shared, &mut actions, &mut registry, preset_id); + + assert_eq!(outcome, PresetApplyOutcome::AppliedNoSync); + assert_eq!( + applied_filter_values(&shared, ®istry), + vec!["dup".to_owned(), "other".to_owned()] + ); + assert_eq!( + applied_search_value_values(&shared, ®istry), + vec!["dup=(\\d+)".to_owned(), "other=(\\d+)".to_owned()] + ); + assert!(!shared.filters.filter_entries[0].enabled); + assert_eq!(shared.filters.filter_entries[0].colors, last_colors); + assert!(!shared.filters.filter_entries[1].enabled); + assert_eq!(shared.filters.filter_entries[1].colors, other_colors); + assert!(!shared.filters.search_value_entries[0].enabled); + assert_eq!( + shared.filters.search_value_entries[0].color, + last_value_color + ); + assert!(!shared.filters.search_value_entries[1].enabled); + assert_eq!( + shared.filters.search_value_entries[1].color, + other_value_color + ); + assert_eq!(registry.filters.filters_map().len(), 2); + assert_eq!(registry.filters.search_value_map().len(), 2); + assert!(drain_commands(&mut cmd_rx).is_empty()); + } + + #[test] + fn apply_preset_handles_missing_id() { + let runtime = Runtime::new().unwrap(); + let (presets, mut cmd_rx, _) = new_presets(); + let mut shared = new_shared(); + let mut actions = new_actions(&runtime); + let mut registry = HostRegistry::default(); + + let outcome = + presets.apply_preset(&mut shared, &mut actions, &mut registry, Uuid::new_v4()); + + assert_eq!(outcome, PresetApplyOutcome::NotFound); + assert!(drain_commands(&mut cmd_rx).is_empty()); + } +} diff --git a/crates/app/src/session/ui/bottom_panel/presets/edit.rs b/crates/app/src/session/ui/bottom_panel/presets/edit.rs new file mode 100644 index 0000000000..7fd0b9b613 --- /dev/null +++ b/crates/app/src/session/ui/bottom_panel/presets/edit.rs @@ -0,0 +1,411 @@ +//! Preset edit state and draft mutations. + +use processor::search::filter::SearchFilter; +use uuid::Uuid; + +use crate::host::ui::registry::{ + HostRegistry, + presets::{Preset, PresetFilterEntry, PresetSearchValueEntry, PresetUpdateOutcome}, +}; + +use super::PresetsUI; + +/// Local draft state for the single preset card currently in edit mode. +#[derive(Debug, Clone)] +pub struct PresetEditState { + /// Preset being edited. + pub preset_id: Uuid, + /// Editable preset name. + pub draft_name: String, + /// Editable filter row snapshots. + pub draft_filters: Vec, + /// Editable chart/search-value row snapshots. + pub draft_search_values: Vec, + /// Whether the next render should autofocus the draft name. + pub first_render_frame: bool, +} + +impl PresetsUI { + /// Starts editing from the current stored preset snapshot. + pub fn start_edit_from_preset(&mut self, preset: &Preset) { + self.edit_state = Some(PresetEditState::from_preset(preset)); + } + + /// Saves the active preset edit draft when it matches the requested preset. + pub fn save_edit(&mut self, registry: &mut HostRegistry, preset_id: Uuid) { + let Some(edit_state) = self.edit_state.as_ref() else { + return; + }; + if edit_state.preset_id != preset_id { + return; + } + + let draft_name = edit_state.draft_name.clone(); + let draft_filters = edit_state.draft_filters.clone(); + let draft_search_values = edit_state.draft_search_values.clone(); + match registry.presets.update_preset( + preset_id, + draft_name, + draft_filters, + draft_search_values, + ) { + PresetUpdateOutcome::NotFound => self.sync_edit_state(registry), + PresetUpdateOutcome::Unchanged | PresetUpdateOutcome::Updated { .. } => { + self.edit_state = None; + } + } + } + + /// Cancels the active preset edit draft when it matches the requested preset. + pub fn cancel_edit(&mut self, preset_id: Uuid) { + if self + .edit_state + .as_ref() + .is_some_and(|state| state.preset_id == preset_id) + { + self.edit_state = None; + } + } + + /// Adds a filter row to the active edit draft. + pub fn add_filter_to_draft(&mut self, preset_id: Uuid, filter: SearchFilter) -> bool { + let Some(edit_state) = self.edit_state.as_mut() else { + return false; + }; + if edit_state.preset_id != preset_id + || edit_state + .draft_filters + .iter() + .any(|entry| entry.filter == filter) + { + return false; + } + + let entry = PresetFilterEntry::with_next_color(filter, &edit_state.draft_filters); + edit_state.draft_filters.push(entry); + true + } + + /// Adds a chart/search-value row to the active edit draft. + pub fn add_search_value_to_draft( + &mut self, + preset_id: Uuid, + search_value: SearchFilter, + ) -> bool { + let Some(edit_state) = self.edit_state.as_mut() else { + return false; + }; + if edit_state.preset_id != preset_id + || edit_state + .draft_search_values + .iter() + .any(|entry| entry.filter == search_value) + { + return false; + } + + let entry = + PresetSearchValueEntry::with_next_color(search_value, &edit_state.draft_search_values); + edit_state.draft_search_values.push(entry); + true + } + + /// Toggles a filter row in the active edit draft. + pub fn toggle_filter_in_draft(&mut self, preset_id: Uuid, index: usize) -> bool { + let Some(edit_state) = self.edit_state.as_mut() else { + return false; + }; + if edit_state.preset_id != preset_id { + return false; + } + + let Some(entry) = edit_state.draft_filters.get_mut(index) else { + return false; + }; + entry.enabled = !entry.enabled; + true + } + + /// Toggles a chart/search-value row in the active edit draft. + pub fn toggle_search_value_in_draft(&mut self, preset_id: Uuid, index: usize) -> bool { + let Some(edit_state) = self.edit_state.as_mut() else { + return false; + }; + if edit_state.preset_id != preset_id { + return false; + } + + let Some(entry) = edit_state.draft_search_values.get_mut(index) else { + return false; + }; + entry.enabled = !entry.enabled; + true + } + + /// Removes a filter row from the active edit draft. + pub fn remove_filter_from_draft(&mut self, preset_id: Uuid, index: usize) -> bool { + let Some(edit_state) = self.edit_state.as_mut() else { + return false; + }; + if edit_state.preset_id != preset_id || index >= edit_state.draft_filters.len() { + return false; + } + + edit_state.draft_filters.remove(index); + true + } + + /// Removes a chart/search-value row from the active edit draft. + pub fn remove_search_value_from_draft(&mut self, preset_id: Uuid, index: usize) -> bool { + let Some(edit_state) = self.edit_state.as_mut() else { + return false; + }; + if edit_state.preset_id != preset_id || index >= edit_state.draft_search_values.len() { + return false; + } + + edit_state.draft_search_values.remove(index); + true + } + + /// Moves a filter row within the active edit draft. + pub fn move_filter_in_draft(&mut self, preset_id: Uuid, from: usize, to: usize) -> bool { + let Some(edit_state) = self.edit_state.as_mut() else { + return false; + }; + if edit_state.preset_id != preset_id { + return false; + } + + move_item(&mut edit_state.draft_filters, from, to) + } + + /// Moves a chart/search-value row within the active edit draft. + pub fn move_search_value_in_draft(&mut self, preset_id: Uuid, from: usize, to: usize) -> bool { + let Some(edit_state) = self.edit_state.as_mut() else { + return false; + }; + if edit_state.preset_id != preset_id { + return false; + } + + move_item(&mut edit_state.draft_search_values, from, to) + } +} + +fn move_item(items: &mut Vec, from: usize, to: usize) -> bool { + if from >= items.len() || to >= items.len() || from == to { + return false; + } + + let item = items.remove(from); + items.insert(to, item); + true +} + +impl PresetEditState { + /// Creates an edit draft from a stored preset snapshot. + pub fn from_preset(preset: &Preset) -> Self { + Self { + preset_id: preset.id, + draft_name: preset.name.clone(), + draft_filters: preset.filters.clone(), + draft_search_values: preset.search_values.clone(), + first_render_frame: true, + } + } +} + +#[cfg(test)] +mod tests { + use processor::search::filter::SearchFilter; + use tokio::sync::mpsc; + use uuid::Uuid; + + use crate::{ + host::{ + command::HostCommand, + ui::registry::{ + HostRegistry, + presets::{Preset, PresetFilterEntry, PresetSearchValueEntry}, + }, + }, + session::command::SessionCommand, + }; + + use super::*; + + fn new_presets() -> ( + PresetsUI, + mpsc::Receiver, + mpsc::Receiver, + ) { + let (cmd_tx, cmd_rx) = mpsc::channel(8); + let (host_cmd_tx, host_cmd_rx) = mpsc::channel(8); + (PresetsUI::new(cmd_tx, host_cmd_tx), cmd_rx, host_cmd_rx) + } + + fn filter_entries(filters: Vec) -> Vec { + Preset::with_default_state(Uuid::new_v4(), "preset".to_owned(), filters, vec![]).filters + } + + fn search_value_entries(search_values: Vec) -> Vec { + Preset::with_default_state(Uuid::new_v4(), "preset".to_owned(), vec![], search_values) + .search_values + } + + fn add_preset_with_default_state( + registry: &mut HostRegistry, + name: &str, + filters: Vec, + search_values: Vec, + ) -> Uuid { + let preset = + Preset::with_default_state(Uuid::new_v4(), name.to_owned(), filters, search_values); + registry + .presets + .add_preset(preset.name, preset.filters, preset.search_values) + } + + #[test] + fn edit_switches_cards() { + let (mut presets, _, _) = new_presets(); + let mut registry = HostRegistry::default(); + let first_id = add_preset_with_default_state(&mut registry, "first", vec![], vec![]); + let second_id = add_preset_with_default_state(&mut registry, "second", vec![], vec![]); + presets.start_edit_from_preset(registry.presets.get(&first_id).unwrap()); + presets.edit_state.as_mut().unwrap().draft_name = "draft".to_owned(); + + presets.start_edit_from_preset(registry.presets.get(&second_id).unwrap()); + + let edit_state = presets.edit_state.as_ref().unwrap(); + assert_eq!(edit_state.preset_id, second_id); + assert_eq!(edit_state.draft_name, "second"); + } + + #[test] + fn move_filter_repositions_item() { + let (mut presets, _, _) = new_presets(); + let mut registry = HostRegistry::default(); + let preset_id = add_preset_with_default_state( + &mut registry, + "first", + vec![ + SearchFilter::plain("one"), + SearchFilter::plain("two"), + SearchFilter::plain("three"), + SearchFilter::plain("four"), + ], + vec![], + ); + presets.start_edit_from_preset(registry.presets.get(&preset_id).unwrap()); + + assert!(presets.move_filter_in_draft(preset_id, 1, 3)); + + let edit_state = presets.edit_state.as_ref().unwrap(); + assert_eq!( + edit_state + .draft_filters + .iter() + .map(|entry| entry.filter.value.as_str()) + .collect::>(), + vec!["one", "three", "four", "two"] + ); + } + + #[test] + fn cancel_discards_draft() { + let (mut presets, _, _) = new_presets(); + let mut registry = HostRegistry::default(); + let preset_id = add_preset_with_default_state( + &mut registry, + "first", + vec![], + vec![SearchFilter::plain("one")], + ); + presets.start_edit_from_preset(registry.presets.get(&preset_id).unwrap()); + let edit_state = presets.edit_state.as_mut().unwrap(); + edit_state.draft_name = "changed".to_owned(); + edit_state.draft_search_values.clear(); + + presets.cancel_edit(preset_id); + + assert!(presets.edit_state.is_none()); + let preset = registry.presets.get(&preset_id).unwrap(); + assert_eq!(preset.name, "first"); + assert_eq!(preset.search_values.len(), 1); + } + + #[test] + fn toggle_updates_draft_enabled_state() { + let (mut presets, _, _) = new_presets(); + let mut registry = HostRegistry::default(); + let preset_id = add_preset_with_default_state( + &mut registry, + "first", + vec![SearchFilter::plain("filter")], + vec![SearchFilter::plain("chart")], + ); + presets.start_edit_from_preset(registry.presets.get(&preset_id).unwrap()); + + assert!(presets.toggle_filter_in_draft(preset_id, 0)); + assert!(presets.toggle_search_value_in_draft(preset_id, 0)); + + let edit_state = presets.edit_state.as_ref().unwrap(); + assert!(!edit_state.draft_filters[0].enabled); + assert!(!edit_state.draft_search_values[0].enabled); + } + + #[test] + fn save_commits_draft() { + let (mut presets, _, _) = new_presets(); + let mut registry = HostRegistry::default(); + let first_id = add_preset_with_default_state( + &mut registry, + "first", + vec![SearchFilter::plain("one").ignore_case(true)], + vec![], + ); + add_preset_with_default_state(&mut registry, "taken", vec![], vec![]); + add_preset_with_default_state(&mut registry, "taken_2", vec![], vec![]); + presets.start_edit_from_preset(registry.presets.get(&first_id).unwrap()); + let edit_state = presets.edit_state.as_mut().unwrap(); + edit_state.draft_name = "taken".to_owned(); + edit_state.draft_filters = filter_entries(vec![ + SearchFilter::plain("warn").ignore_case(true), + SearchFilter::plain("error").ignore_case(true), + ]); + edit_state.draft_search_values = search_value_entries(vec![ + SearchFilter::plain("duration=(\\d+)") + .regex(true) + .ignore_case(true), + ]); + edit_state.draft_filters[1].enabled = false; + edit_state.draft_search_values[0].enabled = false; + + presets.save_edit(&mut registry, first_id); + + let preset = registry.presets.get(&first_id).unwrap(); + assert_eq!(preset.name, "taken_3"); + assert_eq!( + preset + .filters + .iter() + .map(|entry| entry.filter.value.clone()) + .collect::>(), + vec!["warn".to_owned(), "error".to_owned()] + ); + assert_eq!( + preset + .search_values + .iter() + .map(|entry| entry.filter.value.clone()) + .collect::>(), + vec!["duration=(\\d+)".to_owned()] + ); + assert!(preset.filters[0].enabled); + assert!(!preset.filters[1].enabled); + assert!(!preset.search_values[0].enabled); + assert!(presets.edit_state.is_none()); + } +} diff --git a/crates/app/src/session/ui/bottom_panel/presets/export.rs b/crates/app/src/session/ui/bottom_panel/presets/export.rs new file mode 100644 index 0000000000..feedb3db3c --- /dev/null +++ b/crates/app/src/session/ui/bottom_panel/presets/export.rs @@ -0,0 +1,337 @@ +//! Preset export mode selection and overlay rendering. + +use egui::{Align, Frame, Layout, Margin, Rect, Ui, UiBuilder, Widget, vec2}; +use rustc_hash::FxHashSet; +use uuid::Uuid; + +use crate::{ + common::ui::buttons, + host::ui::{ + UiActions, + actions::{FileDialogFilter, FileDialogOptions}, + registry::HostRegistry, + }, +}; + +use super::{EXPORT_PRESETS_DIALOG_ID, PRESETS_EXPORT_FILE_NAME, PresetsUI}; + +/// Persistent selection for the temporary export-only interaction mode. +#[derive(Debug, Clone)] +pub struct ExportSelectionState { + /// Presets currently selected for export. + pub selected_ids: FxHashSet, + /// Preset catalog revision represented by the selection. + pub cached_revision: u64, +} + +impl PresetsUI { + /// Renders the export-mode selection controls over the preset list. + pub fn render_export_overlay( + &mut self, + actions: &mut UiActions, + registry: &HostRegistry, + view_rect: Rect, + ui: &mut Ui, + ) { + let can_select_filtered = self.has_visible_presets(registry); + let selected_count = self.selected_export_count(); + let overlay_rect = view_rect.shrink2(vec2(6.0, 4.0)); + let mut overlay_ui = ui.new_child( + UiBuilder::new() + .max_rect(overlay_rect) + .layout(Layout::bottom_up(Align::RIGHT)), + ); + + let visuals = overlay_ui.visuals(); + Frame::new() + .fill(visuals.panel_fill) + .stroke(visuals.window_stroke) + .corner_radius(visuals.widgets.inactive.corner_radius) + .inner_margin(Margin::symmetric(6, 4)) + .show(&mut overlay_ui, |ui| { + ui.horizontal(|ui| { + let button_size = vec2(100.0, 20.0); + + let cancel = buttons::bottom_panel("Cancel") + .min_size(button_size) + .ui(ui) + .on_hover_text("Exit export mode"); + + if cancel.clicked() { + self.cancel_export_mode(); + } + + let export_btn = buttons::bottom_panel(format!("Export ({selected_count})")) + .min_size(button_size); + + if ui + .add_enabled(selected_count > 0, export_btn) + .on_disabled_hover_text("Select at least one preset to export") + .on_hover_text("Export the selected named presets") + .clicked() + { + actions.file_dialog.save_file( + EXPORT_PRESETS_DIALOG_ID, + FileDialogOptions::new() + .title("Export Presets") + .file_name(PRESETS_EXPORT_FILE_NAME) + .filters(vec![FileDialogFilter::new( + "JSON (*.json)", + vec!["json".to_owned()], + )]), + ); + } + + let clear_btn = buttons::bottom_panel("Clear").min_size(button_size); + if ui + .add_enabled(selected_count > 0, clear_btn) + .on_disabled_hover_text("All export selections are already cleared") + .on_hover_text("Clear all selected presets") + .clicked() + { + self.clear_export_selection(); + } + + let select_all = buttons::bottom_panel("Select All").min_size(button_size); + if ui + .add_enabled(can_select_filtered, select_all) + .on_disabled_hover_text("No presets match the current filter") + .on_hover_text("Select all filtered") + .clicked() + { + self.select_filtered_for_export(registry); + } + }); + }); + } + + /// Prunes export selection state after preset catalog changes. + pub fn sync_export_state(&mut self, registry: &HostRegistry) { + let Some(export_state) = self.export_state.as_mut() else { + return; + }; + + let revision = registry.presets.definitions_revision(); + if export_state.cached_revision == revision { + return; + } + + export_state + .selected_ids + .retain(|preset_id| registry.presets.get(preset_id).is_some()); + export_state.cached_revision = revision; + + if registry.presets.presets().is_empty() { + self.export_state = None; + } + } + + /// Returns whether the panel is in export selection mode. + pub fn is_exporting(&self) -> bool { + self.export_state.is_some() + } + + /// Returns whether a preset is selected for export. + pub fn is_selected_for_export(&self, preset_id: Uuid) -> bool { + self.export_state + .as_ref() + .is_some_and(|state| state.selected_ids.contains(&preset_id)) + } + + /// Returns the number of presets selected for export. + pub fn selected_export_count(&self) -> usize { + self.export_state + .as_ref() + .map_or(0, |state| state.selected_ids.len()) + } + + /// Returns whether any presets match the current query filter. + pub fn has_visible_presets(&self, registry: &HostRegistry) -> bool { + registry + .presets + .presets() + .iter() + .any(|preset| self.query_state.matches(&preset.id)) + } + + /// Enters export mode with all current presets selected. + pub fn start_export_mode(&mut self, registry: &HostRegistry) { + let selected_ids = registry + .presets + .presets() + .iter() + .map(|preset| preset.id) + .collect(); + let export_state = ExportSelectionState { + selected_ids, + cached_revision: registry.presets.definitions_revision(), + }; + self.export_state = Some(export_state); + } + + /// Exits export mode and discards its selection state. + pub fn cancel_export_mode(&mut self) { + self.export_state = None; + } + + /// Clears all export selections. + pub fn clear_export_selection(&mut self) { + let Some(export_state) = self.export_state.as_mut() else { + return; + }; + + export_state.selected_ids.clear(); + } + + /// Selects all presets visible under the current query filter. + pub fn select_filtered_for_export(&mut self, registry: &HostRegistry) { + let selected_ids = registry + .presets + .presets() + .iter() + .filter_map(|preset| self.query_state.matches(&preset.id).then_some(preset.id)) + .collect(); + + let Some(export_state) = self.export_state.as_mut() else { + return; + }; + + export_state.selected_ids = selected_ids; + } + + /// Toggles whether a preset is selected for export. + pub fn toggle_export_selection(&mut self, preset_id: Uuid) { + let Some(export_state) = self.export_state.as_mut() else { + return; + }; + + if !export_state.selected_ids.remove(&preset_id) { + export_state.selected_ids.insert(preset_id); + } + } +} + +#[cfg(test)] +mod tests { + use processor::search::filter::SearchFilter; + use tokio::sync::mpsc; + use uuid::Uuid; + + use crate::{ + host::{ + command::HostCommand, + ui::registry::{HostRegistry, presets::Preset}, + }, + session::command::SessionCommand, + }; + + use super::*; + use crate::session::ui::bottom_panel::presets::query::collect_matching_preset_ids; + + fn new_presets() -> ( + PresetsUI, + mpsc::Receiver, + mpsc::Receiver, + ) { + let (cmd_tx, cmd_rx) = mpsc::channel(8); + let (host_cmd_tx, host_cmd_rx) = mpsc::channel(8); + (PresetsUI::new(cmd_tx, host_cmd_tx), cmd_rx, host_cmd_rx) + } + + fn add_preset_with_default_state( + registry: &mut HostRegistry, + name: &str, + filters: Vec, + search_values: Vec, + ) -> Uuid { + let preset = + Preset::with_default_state(Uuid::new_v4(), name.to_owned(), filters, search_values); + registry + .presets + .add_preset(preset.name, preset.filters, preset.search_values) + } + + #[test] + fn export_mode_selects_all() { + let (mut presets, _, _) = new_presets(); + let mut registry = HostRegistry::default(); + let first_id = add_preset_with_default_state(&mut registry, "first", vec![], vec![]); + let second_id = add_preset_with_default_state(&mut registry, "second", vec![], vec![]); + + presets.start_export_mode(®istry); + + assert!(presets.is_exporting()); + assert_eq!(presets.selected_export_count(), 2); + assert!(presets.is_selected_for_export(first_id)); + assert!(presets.is_selected_for_export(second_id)); + } + + #[test] + fn export_mode_prunes_deleted() { + let (mut presets, _, _) = new_presets(); + let mut registry = HostRegistry::default(); + let first_id = add_preset_with_default_state(&mut registry, "first", vec![], vec![]); + let second_id = add_preset_with_default_state(&mut registry, "second", vec![], vec![]); + presets.start_export_mode(®istry); + + assert!(registry.presets.remove_preset(second_id)); + presets.sync_export_state(®istry); + + assert!(presets.is_selected_for_export(first_id)); + assert!(!presets.is_selected_for_export(second_id)); + assert_eq!(presets.selected_export_count(), 1); + } + + #[test] + fn select_filtered_replaces_selection() { + let (mut presets, _, _) = new_presets(); + let mut registry = HostRegistry::default(); + let error_id = add_preset_with_default_state(&mut registry, "Errors", vec![], vec![]); + let warn_id = add_preset_with_default_state(&mut registry, "Warnings", vec![], vec![]); + let other_error_id = + add_preset_with_default_state(&mut registry, "Error Group", vec![], vec![]); + presets.start_export_mode(®istry); + presets.query_state.query = "error".to_owned(); + presets.query_state.update_with_revision( + registry.presets.definitions_revision(), + true, + |matcher| collect_matching_preset_ids(matcher, ®istry), + ); + + presets.select_filtered_for_export(®istry); + + assert!(presets.is_selected_for_export(error_id)); + assert!(!presets.is_selected_for_export(warn_id)); + assert!(presets.is_selected_for_export(other_error_id)); + assert_eq!(presets.selected_export_count(), 2); + } + + #[test] + fn select_filtered_uses_all_when_empty() { + let (mut presets, _, _) = new_presets(); + let mut registry = HostRegistry::default(); + let first_id = add_preset_with_default_state(&mut registry, "Errors", vec![], vec![]); + let second_id = add_preset_with_default_state(&mut registry, "Warnings", vec![], vec![]); + presets.start_export_mode(®istry); + presets.toggle_export_selection(first_id); + + presets.select_filtered_for_export(®istry); + + assert!(presets.is_selected_for_export(first_id)); + assert!(presets.is_selected_for_export(second_id)); + assert_eq!(presets.selected_export_count(), 2); + } + + #[test] + fn clear_selection_empties_export_state() { + let (mut presets, _, _) = new_presets(); + let mut registry = HostRegistry::default(); + let first_id = add_preset_with_default_state(&mut registry, "Errors", vec![], vec![]); + presets.start_export_mode(®istry); + + presets.clear_export_selection(); + + assert!(!presets.is_selected_for_export(first_id)); + assert_eq!(presets.selected_export_count(), 0); + } +} diff --git a/crates/app/src/session/ui/bottom_panel/presets/mod.rs b/crates/app/src/session/ui/bottom_panel/presets/mod.rs index 119f8d53dd..a99a47626f 100644 --- a/crates/app/src/session/ui/bottom_panel/presets/mod.rs +++ b/crates/app/src/session/ui/bottom_panel/presets/mod.rs @@ -1,12 +1,14 @@ -use egui::{Align, Frame, Layout, Margin, RichText, ScrollArea, Ui, UiBuilder, Widget, vec2}; +//! Preset tab state and interactions for the session bottom panel. + +use std::path::PathBuf; + +use egui::{Align, Layout, RichText, ScrollArea, Ui, Widget, vec2}; use processor::search::filter::SearchFilter; -use rustc_hash::FxHashSet; use tokio::sync::mpsc::Sender; use uuid::Uuid; use crate::{ common::{ - matcher::substring_matcher::SubstringMatcher, phosphor::icons, ui::{buttons, visibility_tracker::VisibilityTracker}, }, @@ -17,21 +19,19 @@ use crate::{ ui::{ UiActions, actions::{FileDialogFilter, FileDialogOptions}, - registry::{ - HostRegistry, - filters::{FilterDefinition, SearchValueDefinition}, - presets::{Preset, PresetUpdateOutcome}, - }, + registry::HostRegistry, }, }, - session::{ - command::SessionCommand, - ui::shared::{SearchSyncTarget, SessionShared}, - }, + session::{command::SessionCommand, ui::shared::SessionShared}, }; -use query::collect_matching_preset_ids; +use edit::PresetEditState; +use export::ExportSelectionState; +use query::{PresetQueryState, collect_matching_preset_ids}; +mod apply; +mod edit; +mod export; mod query; mod render; @@ -39,20 +39,6 @@ const IMPORT_PRESETS_DIALOG_ID: &str = "import_presets"; const EXPORT_PRESETS_DIALOG_ID: &str = "export_presets"; const PRESETS_EXPORT_FILE_NAME: &str = "chipmunk-presets.json"; -mod card_metrics { - pub const PRESET_CARD_WIDTH: f32 = 280.0; - pub const PRESET_CARD_HEIGHT: f32 = 160.0; - pub const PRESET_CARD_INNER_MARGIN_X: i8 = 12; - pub const PRESET_CARD_INNER_MARGIN_Y: i8 = 8; - pub const PRESET_CARD_OUTER_MARGIN_Y: i8 = 4; - pub const PRESET_CARD_HEADER_GAP: f32 = 4.0; - pub const PRESET_EDIT_ITEM_ICON_SIZE: f32 = 12.0; - pub const PRESET_CARD_CONTENT_WIDTH: f32 = - PRESET_CARD_WIDTH - (PRESET_CARD_INNER_MARGIN_X as f32 * 2.0); - pub const PRESET_CARD_CONTENT_HEIGHT: f32 = PRESET_CARD_HEIGHT - - ((PRESET_CARD_INNER_MARGIN_Y as f32 + PRESET_CARD_OUTER_MARGIN_Y as f32) * 2.0); -} - /// Immediate-mode state for the presets tab surface. #[derive(Debug)] pub struct PresetsUI { @@ -67,74 +53,39 @@ pub struct PresetsUI { scroll_to_preset: Option, } -/// Cached name-filter state keyed by the preset catalog revision. -#[derive(Debug, Default)] -struct PresetQueryState { - query: String, - matcher: SubstringMatcher, - // `None` means the query is empty and every preset stays visible. - matching_ids: Option>, - cached_revision: u64, -} - -/// Render-time metadata for a single editable preset row. -#[derive(Debug, Clone, Copy)] -struct PresetItemRow<'a> { - label: &'a str, - index: usize, - len: usize, -} - -/// Logical sections shared by preset browse and edit rendering. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum PresetBrowseSection { - Filter, - SearchValue, -} - -/// Local draft state for the single preset card currently in edit mode. -#[derive(Debug, Clone)] -struct PresetEditState { - preset_id: Uuid, - draft_name: String, - draft_filters: Vec, - draft_search_values: Vec, - // Used to autofocus the draft name exactly once when entering edit mode. - first_render_frame: bool, -} - -/// Persistent selection for the temporary export-only interaction mode. -#[derive(Debug, Clone)] -struct ExportSelectionState { - selected_ids: FxHashSet, - cached_revision: u64, -} - -/// Result of applying a preset into the current session state. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum PresetApplyOutcome { - NotFound, - NoChanges, - Applied(SearchSyncTarget), -} - /// Deferred UI intents emitted while rendering preset cards. #[derive(Debug, Clone)] -enum PresetAction { +pub enum PresetAction { + /// Save the active edit draft for a preset. SaveEdit(Uuid), + /// Cancel the active edit draft for a preset. CancelEdit(Uuid), + /// Apply a preset to the current session. Apply(Uuid), + /// Delete a preset. Delete(Uuid), + /// Toggle whether a preset is included in the export selection. ToggleExportSelection(Uuid), + /// Add a filter to a preset edit draft. AddFilter(Uuid, SearchFilter), + /// Add a chart/search-value to a preset edit draft. AddSearchValue(Uuid, SearchFilter), + /// Toggle a filter row in a preset edit draft. + ToggleFilterEnabled(Uuid, usize), + /// Toggle a chart/search-value row in a preset edit draft. + ToggleSearchValueEnabled(Uuid, usize), + /// Remove a filter from a preset edit draft. RemoveFilter(Uuid, usize), + /// Remove a chart/search-value from a preset edit draft. RemoveSearchValue(Uuid, usize), + /// Move a filter row within a preset edit draft. MoveFilter(Uuid, usize, usize), + /// Move a chart/search-value row within a preset edit draft. MoveSearchValue(Uuid, usize, usize), } impl PresetsUI { + /// Creates preset panel state wired to session and host command channels. pub fn new(cmd_tx: Sender, host_cmd_tx: Sender) -> Self { Self { cmd_tx, @@ -147,6 +98,7 @@ impl PresetsUI { } } + /// Renders the preset panel and processes deferred UI actions. pub fn render_content( &mut self, shared: &mut SessionShared, @@ -297,85 +249,6 @@ impl PresetsUI { } } - fn render_export_overlay( - &mut self, - actions: &mut UiActions, - registry: &HostRegistry, - view_rect: egui::Rect, - ui: &mut Ui, - ) { - let can_select_filtered = self.has_visible_presets(registry); - let selected_count = self.selected_export_count(); - let overlay_rect = view_rect.shrink2(vec2(6.0, 4.0)); - let mut overlay_ui = ui.new_child( - UiBuilder::new() - .max_rect(overlay_rect) - .layout(Layout::bottom_up(Align::RIGHT)), - ); - - let visuals = overlay_ui.visuals(); - Frame::new() - .fill(visuals.panel_fill) - .stroke(visuals.window_stroke) - .corner_radius(visuals.widgets.inactive.corner_radius) - .inner_margin(Margin::symmetric(6, 4)) - .show(&mut overlay_ui, |ui| { - ui.horizontal(|ui| { - let button_size = vec2(100.0, 20.0); - - let cancel = buttons::bottom_panel("Cancel") - .min_size(button_size) - .ui(ui) - .on_hover_text("Exit export mode"); - - if cancel.clicked() { - self.cancel_export_mode(); - } - - let export_btn = buttons::bottom_panel(format!("Export ({selected_count})")) - .min_size(button_size); - - if ui - .add_enabled(selected_count > 0, export_btn) - .on_disabled_hover_text("Select at least one preset to export") - .on_hover_text("Export the selected named presets") - .clicked() - { - actions.file_dialog.save_file( - EXPORT_PRESETS_DIALOG_ID, - FileDialogOptions::new() - .title("Export Presets") - .file_name(PRESETS_EXPORT_FILE_NAME) - .filters(vec![FileDialogFilter::new( - "JSON (*.json)", - vec!["json".to_owned()], - )]), - ); - } - - let clear_btn = buttons::bottom_panel("Clear").min_size(button_size); - if ui - .add_enabled(selected_count > 0, clear_btn) - .on_disabled_hover_text("All export selections are already cleared") - .on_hover_text("Clear all selected presets") - .clicked() - { - self.clear_export_selection(); - } - - let select_all = buttons::bottom_panel("Select All").min_size(button_size); - if ui - .add_enabled(can_select_filtered, select_all) - .on_disabled_hover_text("No presets match the current filter") - .on_hover_text("Select all filtered") - .clicked() - { - self.select_filtered_for_export(registry); - } - }); - }); - } - fn handle_preset_action( &mut self, action: PresetAction, @@ -399,6 +272,12 @@ impl PresetsUI { PresetAction::AddSearchValue(id, filter) => { self.add_search_value_to_draft(id, filter); } + PresetAction::ToggleFilterEnabled(id, index) => { + self.toggle_filter_in_draft(id, index); + } + PresetAction::ToggleSearchValueEnabled(id, index) => { + self.toggle_search_value_in_draft(id, index); + } PresetAction::RemoveFilter(id, index) => { self.remove_filter_from_draft(id, index); } @@ -424,26 +303,6 @@ impl PresetsUI { } } - fn sync_export_state(&mut self, registry: &HostRegistry) { - let Some(export_state) = self.export_state.as_mut() else { - return; - }; - - let revision = registry.presets.definitions_revision(); - if export_state.cached_revision == revision { - return; - } - - export_state - .selected_ids - .retain(|preset_id| registry.presets.get(preset_id).is_some()); - export_state.cached_revision = revision; - - if registry.presets.presets().is_empty() { - self.export_state = None; - } - } - fn handle_file_dialog_output(&mut self, actions: &mut UiActions, registry: &HostRegistry) { let Some((dialog_id, paths)) = actions .file_dialog @@ -472,7 +331,7 @@ impl PresetsUI { } } - fn dispatch_import_request(&self, actions: &mut UiActions, path: std::path::PathBuf) -> bool { + fn dispatch_import_request(&self, actions: &mut UiActions, path: PathBuf) -> bool { actions.try_send_command(&self.host_cmd_tx, HostCommand::ImportPresets(path)) } @@ -480,7 +339,7 @@ impl PresetsUI { &self, actions: &mut UiActions, registry: &HostRegistry, - path: std::path::PathBuf, + path: PathBuf, ) -> bool { let presets = match self.export_state.as_ref() { Some(export_state) => registry @@ -499,9 +358,10 @@ impl PresetsUI { return false; } + let params = ExportPresetsParam { path, presets }; actions.try_send_command( &self.host_cmd_tx, - HostCommand::ExportPresets(Box::new(ExportPresetsParam { path, presets })), + HostCommand::ExportPresets(Box::new(params)), ) } @@ -511,189 +371,6 @@ impl PresetsUI { .is_some_and(|state| state.preset_id == preset_id) } - fn is_exporting(&self) -> bool { - self.export_state.is_some() - } - - fn is_selected_for_export(&self, preset_id: Uuid) -> bool { - self.export_state - .as_ref() - .is_some_and(|state| state.selected_ids.contains(&preset_id)) - } - - fn selected_export_count(&self) -> usize { - self.export_state - .as_ref() - .map_or(0, |state| state.selected_ids.len()) - } - - fn has_visible_presets(&self, registry: &HostRegistry) -> bool { - registry - .presets - .presets() - .iter() - .any(|preset| self.query_state.matches(&preset.id)) - } - - fn start_export_mode(&mut self, registry: &HostRegistry) { - self.export_state = Some(ExportSelectionState { - selected_ids: registry - .presets - .presets() - .iter() - .map(|preset| preset.id) - .collect(), - cached_revision: registry.presets.definitions_revision(), - }); - } - - fn cancel_export_mode(&mut self) { - self.export_state = None; - } - - fn clear_export_selection(&mut self) { - let Some(export_state) = self.export_state.as_mut() else { - return; - }; - - export_state.selected_ids.clear(); - } - - fn select_filtered_for_export(&mut self, registry: &HostRegistry) { - let selected_ids = registry - .presets - .presets() - .iter() - .filter_map(|preset| self.query_state.matches(&preset.id).then_some(preset.id)) - .collect(); - - let Some(export_state) = self.export_state.as_mut() else { - return; - }; - - export_state.selected_ids = selected_ids; - } - - fn toggle_export_selection(&mut self, preset_id: Uuid) { - let Some(export_state) = self.export_state.as_mut() else { - return; - }; - - if !export_state.selected_ids.remove(&preset_id) { - export_state.selected_ids.insert(preset_id); - } - } - - fn start_edit_from_preset(&mut self, preset: &Preset) { - self.edit_state = Some(PresetEditState::from_preset(preset)); - } - - fn save_edit(&mut self, registry: &mut HostRegistry, preset_id: Uuid) { - let Some(edit_state) = self.edit_state.as_ref() else { - return; - }; - if edit_state.preset_id != preset_id { - return; - } - - let draft_name = edit_state.draft_name.clone(); - let draft_filters = edit_state.draft_filters.clone(); - let draft_search_values = edit_state.draft_search_values.clone(); - match registry.presets.update_preset( - preset_id, - draft_name, - draft_filters, - draft_search_values, - ) { - PresetUpdateOutcome::NotFound => self.sync_edit_state(registry), - PresetUpdateOutcome::Unchanged | PresetUpdateOutcome::Updated { .. } => { - self.edit_state = None; - } - } - } - - fn cancel_edit(&mut self, preset_id: Uuid) { - if self - .edit_state - .as_ref() - .is_some_and(|state| state.preset_id == preset_id) - { - self.edit_state = None; - } - } - - fn add_filter_to_draft(&mut self, preset_id: Uuid, filter: SearchFilter) -> bool { - let Some(edit_state) = self.edit_state.as_mut() else { - return false; - }; - if edit_state.preset_id != preset_id || edit_state.draft_filters.contains(&filter) { - return false; - } - - edit_state.draft_filters.push(filter); - true - } - - fn add_search_value_to_draft(&mut self, preset_id: Uuid, search_value: SearchFilter) -> bool { - let Some(edit_state) = self.edit_state.as_mut() else { - return false; - }; - if edit_state.preset_id != preset_id - || edit_state.draft_search_values.contains(&search_value) - { - return false; - } - - edit_state.draft_search_values.push(search_value); - true - } - - fn remove_filter_from_draft(&mut self, preset_id: Uuid, index: usize) -> bool { - let Some(edit_state) = self.edit_state.as_mut() else { - return false; - }; - if edit_state.preset_id != preset_id || index >= edit_state.draft_filters.len() { - return false; - } - - edit_state.draft_filters.remove(index); - true - } - - fn remove_search_value_from_draft(&mut self, preset_id: Uuid, index: usize) -> bool { - let Some(edit_state) = self.edit_state.as_mut() else { - return false; - }; - if edit_state.preset_id != preset_id || index >= edit_state.draft_search_values.len() { - return false; - } - - edit_state.draft_search_values.remove(index); - true - } - - fn move_filter_in_draft(&mut self, preset_id: Uuid, from: usize, to: usize) -> bool { - let Some(edit_state) = self.edit_state.as_mut() else { - return false; - }; - if edit_state.preset_id != preset_id { - return false; - } - - move_item(&mut edit_state.draft_filters, from, to) - } - - fn move_search_value_in_draft(&mut self, preset_id: Uuid, from: usize, to: usize) -> bool { - let Some(edit_state) = self.edit_state.as_mut() else { - return false; - }; - if edit_state.preset_id != preset_id { - return false; - } - - move_item(&mut edit_state.draft_search_values, from, to) - } - fn delete_preset(&mut self, registry: &mut HostRegistry, preset_id: Uuid) -> bool { if !registry.presets.remove_preset(preset_id) { return false; @@ -714,87 +391,6 @@ impl PresetsUI { self.scroll_to_preset = Some(preset_id); preset_id } - - fn apply_preset( - &self, - shared: &mut SessionShared, - actions: &mut UiActions, - registry: &mut HostRegistry, - preset_id: Uuid, - ) -> PresetApplyOutcome { - let Some((filters, search_values)) = registry - .presets - .get(&preset_id) - .map(|preset| (preset.filters.clone(), preset.search_values.clone())) - else { - return PresetApplyOutcome::NotFound; - }; - - // Materialize preset semantics through the normal registry/session path. - // Existing applied rows, including disabled ones, are left as-is because - // dedupe reuses their ids and the applied check skips re-applying them. - let mut changed_filters = false; - for filter in filters { - let filter_id = registry.filters.add_filter(FilterDefinition::new(filter)); - if shared.filters.is_filter_applied(&filter_id) { - continue; - } - - shared.apply_filter(&mut registry.filters, filter_id); - changed_filters = true; - } - - let mut changed_search_values = false; - for search_value in search_values { - let value_id = registry - .filters - .add_search_value(SearchValueDefinition::new(search_value)); - if shared.filters.is_search_value_applied(&value_id) { - continue; - } - - shared.apply_search_value(&mut registry.filters, value_id); - changed_search_values = true; - } - - let outcome = match (changed_filters, changed_search_values) { - (false, false) => PresetApplyOutcome::NoChanges, - (true, false) => PresetApplyOutcome::Applied(SearchSyncTarget::Filter), - (false, true) => PresetApplyOutcome::Applied(SearchSyncTarget::SearchValue), - (true, true) => PresetApplyOutcome::Applied(SearchSyncTarget::Both), - }; - - if let PresetApplyOutcome::Applied(target) = outcome { - self.dispatch_sync_commands(shared, actions, ®istry.filters, target); - } - - outcome - } - - fn dispatch_sync_commands( - &self, - shared: &mut SessionShared, - actions: &mut UiActions, - registry: &crate::host::ui::registry::filters::FilterRegistry, - target: SearchSyncTarget, - ) { - // Preset apply mutates session state first, then issues the same explicit - // sync commands used by the rest of the search/filter UI. - shared - .sync_search(registry, target) - .into_iter() - .for_each(|cmd| _ = actions.try_send_command(&self.cmd_tx, cmd)); - } -} - -fn move_item(items: &mut Vec, from: usize, to: usize) -> bool { - if from >= items.len() || to >= items.len() || from == to { - return false; - } - - let item = items.remove(from); - items.insert(to, item); - true } fn can_create_preset_from_session(shared: &SessionShared) -> bool { @@ -802,39 +398,33 @@ fn can_create_preset_from_session(shared: &SessionShared) -> bool { !shared.filters.filter_entries.is_empty() || !shared.filters.search_value_entries.is_empty() } -impl PresetEditState { - fn from_preset(preset: &Preset) -> Self { - Self { - preset_id: preset.id, - draft_name: preset.name.clone(), - draft_filters: preset.filters.clone(), - draft_search_values: preset.search_values.clone(), - first_render_frame: true, - } - } -} - #[cfg(test)] mod tests { use std::path::PathBuf; + use processor::search::filter::SearchFilter; + use stypes::{FileFormat, ObserveOrigin}; use tokio::{runtime::Runtime, sync::mpsc}; use uuid::Uuid; - use super::*; - use crate::session::ui::definitions::schema::LogSchemaSpec; use crate::{ host::{ command::HostCommand, common::parsers::ParserNames, - ui::registry::{ - HostRegistry, - filters::{FilterDefinition, SearchValueDefinition}, + ui::{ + UiActions, + registry::{ + HostRegistry, + filters::{FilterDefinition, FilterRegistry, SearchValueDefinition}, + presets::Preset, + }, }, }, session::{command::SessionCommand, types::ObserveOperation, ui::SessionInfo}, }; - use stypes::{FileFormat, ObserveOrigin}; + + use super::*; + use crate::session::ui::definitions::schema::LogSchemaSpec; fn new_presets() -> ( PresetsUI, @@ -868,20 +458,14 @@ mod tests { UiActions::new(runtime.handle().clone()) } - fn add_filter_definition( - registry: &mut crate::host::ui::registry::filters::FilterRegistry, - value: &str, - ) -> Uuid { + fn add_filter_definition(registry: &mut FilterRegistry, value: &str) -> Uuid { let definition = FilterDefinition::new(SearchFilter::plain(value).ignore_case(true)); let id = definition.id; registry.add_filter(definition); id } - fn add_search_value_definition( - registry: &mut crate::host::ui::registry::filters::FilterRegistry, - value: &str, - ) -> Uuid { + fn add_search_value_definition(registry: &mut FilterRegistry, value: &str) -> Uuid { let definition = SearchValueDefinition::new(SearchFilter::plain(value).regex(true).ignore_case(true)); let id = definition.id; @@ -889,12 +473,17 @@ mod tests { id } - fn drain_commands(cmd_rx: &mut mpsc::Receiver) -> Vec { - let mut commands = Vec::new(); - while let Ok(command) = cmd_rx.try_recv() { - commands.push(command); - } - commands + fn add_preset_with_default_state( + registry: &mut HostRegistry, + name: &str, + filters: Vec, + search_values: Vec, + ) -> Uuid { + let preset = + Preset::with_default_state(Uuid::new_v4(), name.to_owned(), filters, search_values); + registry + .presets + .add_preset(preset.name, preset.filters, preset.search_values) } fn drain_host_commands(host_cmd_rx: &mut mpsc::Receiver) -> Vec { @@ -943,94 +532,11 @@ mod tests { assert!(registry.presets.get(&preset_id).is_some()); } - #[test] - fn export_mode_selects_all() { - let (mut presets, _, _) = new_presets(); - let mut registry = HostRegistry::default(); - let first_id = registry.presets.add_preset("first", vec![], vec![]); - let second_id = registry.presets.add_preset("second", vec![], vec![]); - - presets.start_export_mode(®istry); - - assert!(presets.is_exporting()); - assert_eq!(presets.selected_export_count(), 2); - assert!(presets.is_selected_for_export(first_id)); - assert!(presets.is_selected_for_export(second_id)); - } - - #[test] - fn export_mode_prunes_deleted() { - let (mut presets, _, _) = new_presets(); - let mut registry = HostRegistry::default(); - let first_id = registry.presets.add_preset("first", vec![], vec![]); - let second_id = registry.presets.add_preset("second", vec![], vec![]); - presets.start_export_mode(®istry); - - assert!(registry.presets.remove_preset(second_id)); - presets.sync_export_state(®istry); - - assert!(presets.is_selected_for_export(first_id)); - assert!(!presets.is_selected_for_export(second_id)); - assert_eq!(presets.selected_export_count(), 1); - } - - #[test] - fn select_filtered_replaces_selection() { - let (mut presets, _, _) = new_presets(); - let mut registry = HostRegistry::default(); - let error_id = registry.presets.add_preset("Errors", vec![], vec![]); - let warn_id = registry.presets.add_preset("Warnings", vec![], vec![]); - let other_error_id = registry.presets.add_preset("Error Group", vec![], vec![]); - presets.start_export_mode(®istry); - presets.query_state.query = "error".to_owned(); - presets.query_state.update_with_revision( - registry.presets.definitions_revision(), - true, - |matcher| collect_matching_preset_ids(matcher, ®istry), - ); - - presets.select_filtered_for_export(®istry); - - assert!(presets.is_selected_for_export(error_id)); - assert!(!presets.is_selected_for_export(warn_id)); - assert!(presets.is_selected_for_export(other_error_id)); - assert_eq!(presets.selected_export_count(), 2); - } - - #[test] - fn select_filtered_uses_all_when_empty() { - let (mut presets, _, _) = new_presets(); - let mut registry = HostRegistry::default(); - let first_id = registry.presets.add_preset("Errors", vec![], vec![]); - let second_id = registry.presets.add_preset("Warnings", vec![], vec![]); - presets.start_export_mode(®istry); - presets.toggle_export_selection(first_id); - - presets.select_filtered_for_export(®istry); - - assert!(presets.is_selected_for_export(first_id)); - assert!(presets.is_selected_for_export(second_id)); - assert_eq!(presets.selected_export_count(), 2); - } - - #[test] - fn clear_selection_empties_export_state() { - let (mut presets, _, _) = new_presets(); - let mut registry = HostRegistry::default(); - let first_id = registry.presets.add_preset("Errors", vec![], vec![]); - presets.start_export_mode(®istry); - - presets.clear_export_selection(); - - assert!(!presets.is_selected_for_export(first_id)); - assert_eq!(presets.selected_export_count(), 0); - } - #[test] fn delete_clears_editor() { let (mut presets, _, _) = new_presets(); let mut registry = HostRegistry::default(); - let preset_id = registry.presets.add_preset("first", vec![], vec![]); + let preset_id = add_preset_with_default_state(&mut registry, "first", vec![], vec![]); presets.start_edit_from_preset(registry.presets.get(&preset_id).unwrap()); assert!(presets.delete_preset(&mut registry, preset_id)); @@ -1038,290 +544,6 @@ mod tests { assert!(presets.edit_state.is_none()); } - #[test] - fn edit_switches_cards() { - let (mut presets, _, _) = new_presets(); - let mut registry = HostRegistry::default(); - let first_id = registry.presets.add_preset("first", vec![], vec![]); - let second_id = registry.presets.add_preset("second", vec![], vec![]); - presets.start_edit_from_preset(registry.presets.get(&first_id).unwrap()); - presets.edit_state.as_mut().unwrap().draft_name = "draft".to_owned(); - - presets.start_edit_from_preset(registry.presets.get(&second_id).unwrap()); - - let edit_state = presets.edit_state.as_ref().unwrap(); - assert_eq!(edit_state.preset_id, second_id); - assert_eq!(edit_state.draft_name, "second"); - } - - #[test] - fn move_filter_repositions_item() { - let (mut presets, _, _) = new_presets(); - let mut registry = HostRegistry::default(); - let preset_id = registry.presets.add_preset( - "first", - vec![ - SearchFilter::plain("one"), - SearchFilter::plain("two"), - SearchFilter::plain("three"), - SearchFilter::plain("four"), - ], - vec![], - ); - presets.start_edit_from_preset(registry.presets.get(&preset_id).unwrap()); - - assert!(presets.move_filter_in_draft(preset_id, 1, 3)); - - let edit_state = presets.edit_state.as_ref().unwrap(); - assert_eq!( - edit_state - .draft_filters - .iter() - .map(|filter| filter.value.as_str()) - .collect::>(), - vec!["one", "three", "four", "two"] - ); - } - - #[test] - fn cancel_discards_draft() { - let (mut presets, _, _) = new_presets(); - let mut registry = HostRegistry::default(); - let preset_id = - registry - .presets - .add_preset("first", vec![], vec![SearchFilter::plain("one")]); - presets.start_edit_from_preset(registry.presets.get(&preset_id).unwrap()); - let edit_state = presets.edit_state.as_mut().unwrap(); - edit_state.draft_name = "changed".to_owned(); - edit_state.draft_search_values.clear(); - - presets.cancel_edit(preset_id); - - assert!(presets.edit_state.is_none()); - let preset = registry.presets.get(&preset_id).unwrap(); - assert_eq!(preset.name, "first"); - assert_eq!(preset.search_values.len(), 1); - } - - #[test] - fn save_commits_draft() { - let (mut presets, _, _) = new_presets(); - let mut registry = HostRegistry::default(); - let first_id = registry.presets.add_preset( - "first", - vec![SearchFilter::plain("one").ignore_case(true)], - vec![], - ); - registry.presets.add_preset("taken", vec![], vec![]); - registry.presets.add_preset("taken_2", vec![], vec![]); - presets.start_edit_from_preset(registry.presets.get(&first_id).unwrap()); - let edit_state = presets.edit_state.as_mut().unwrap(); - edit_state.draft_name = "taken".to_owned(); - edit_state.draft_filters = vec![ - SearchFilter::plain("warn").ignore_case(true), - SearchFilter::plain("error").ignore_case(true), - ]; - edit_state.draft_search_values = vec![ - SearchFilter::plain("duration=(\\d+)") - .regex(true) - .ignore_case(true), - ]; - - presets.save_edit(&mut registry, first_id); - - let preset = registry.presets.get(&first_id).unwrap(); - assert_eq!(preset.name, "taken_3"); - assert_eq!( - preset - .filters - .iter() - .map(|filter| filter.value.clone()) - .collect::>(), - vec!["warn".to_owned(), "error".to_owned()] - ); - assert_eq!( - preset - .search_values - .iter() - .map(|filter| filter.value.clone()) - .collect::>(), - vec!["duration=(\\d+)".to_owned()] - ); - assert!(presets.edit_state.is_none()); - } - - #[test] - fn apply_preset_skips_existing_rows() { - let runtime = Runtime::new().unwrap(); - let (presets, mut cmd_rx, _) = new_presets(); - let mut shared = new_shared(); - let mut actions = new_actions(&runtime); - let mut registry = HostRegistry::default(); - let filter_id = add_filter_definition(&mut registry.filters, "error"); - let value_id = add_search_value_definition(&mut registry.filters, "duration=(\\d+)"); - - shared - .filters - .apply_filter_with_state(&mut registry.filters, filter_id, false); - shared - .filters - .apply_search_value_with_state(&mut registry.filters, value_id, false); - let original_filter_colors = shared.filters.filter_entries[0].colors.clone(); - let original_value_color = shared.filters.search_value_entries[0].color; - let preset_id = registry.presets.add_preset( - "test", - vec![ - registry - .filters - .get_filter(&filter_id) - .unwrap() - .filter - .clone(), - registry - .filters - .get_filter(&filter_id) - .unwrap() - .filter - .clone(), - ], - vec![ - registry - .filters - .get_search_value(&value_id) - .unwrap() - .filter - .clone(), - registry - .filters - .get_search_value(&value_id) - .unwrap() - .filter - .clone(), - ], - ); - - let outcome = presets.apply_preset(&mut shared, &mut actions, &mut registry, preset_id); - - assert_eq!(outcome, PresetApplyOutcome::NoChanges); - assert_eq!(shared.filters.filter_entries.len(), 1); - assert_eq!(shared.filters.search_value_entries.len(), 1); - assert!(!shared.filters.filter_entries[0].enabled); - assert!(!shared.filters.search_value_entries[0].enabled); - assert_eq!( - shared.filters.filter_entries[0].colors, - original_filter_colors - ); - assert_eq!( - shared.filters.search_value_entries[0].color, - original_value_color - ); - assert_eq!(registry.filters.filters_map().len(), 1); - assert_eq!(registry.filters.search_value_map().len(), 1); - assert!(drain_commands(&mut cmd_rx).is_empty()); - } - - #[test] - fn apply_preset_appends_and_syncs() { - let runtime = Runtime::new().unwrap(); - let (presets, mut cmd_rx, _) = new_presets(); - let mut shared = new_shared(); - let mut actions = new_actions(&runtime); - let mut registry = HostRegistry::default(); - let existing_filter_id = add_filter_definition(&mut registry.filters, "existing"); - let preset_id = registry.presets.add_preset( - "test", - vec![ - SearchFilter::plain("existing").ignore_case(true), - SearchFilter::plain("error").ignore_case(true), - ], - vec![ - SearchFilter::plain("duration=(\\d+)") - .regex(true) - .ignore_case(true), - SearchFilter::plain("latency=(\\d+)") - .regex(true) - .ignore_case(true), - ], - ); - shared - .filters - .apply_filter(&mut registry.filters, existing_filter_id); - - let outcome = presets.apply_preset(&mut shared, &mut actions, &mut registry, preset_id); - - assert_eq!(outcome, PresetApplyOutcome::Applied(SearchSyncTarget::Both)); - assert_eq!( - shared - .filters - .filter_entries - .iter() - .map(|item| { - registry - .filters - .get_filter(&item.id) - .unwrap() - .filter - .value - .clone() - }) - .collect::>(), - vec!["existing".to_owned(), "error".to_owned()] - ); - assert_eq!( - shared - .filters - .search_value_entries - .iter() - .map(|item| { - registry - .filters - .get_search_value(&item.id) - .unwrap() - .filter - .value - .clone() - }) - .collect::>(), - vec!["duration=(\\d+)".to_owned(), "latency=(\\d+)".to_owned()] - ); - - let commands = drain_commands(&mut cmd_rx); - assert_eq!(commands.len(), 2); - match &commands[0] { - SessionCommand::ApplySearchFilter { filters, .. } => { - assert_eq!(filters.len(), 2); - assert_eq!(filters[0].value, "existing"); - assert_eq!(filters[1].value, "error"); - } - other => panic!("expected ApplySearchFilter command, got {other:?}"), - } - match &commands[1] { - SessionCommand::ApplySearchValuesFilter { filters, .. } => { - assert_eq!( - filters, - &vec!["duration=(\\d+)".to_owned(), "latency=(\\d+)".to_owned()] - ); - } - other => panic!("expected ApplySearchValuesFilter command, got {other:?}"), - } - } - - #[test] - fn apply_preset_handles_missing_id() { - let runtime = Runtime::new().unwrap(); - let (presets, mut cmd_rx, _) = new_presets(); - let mut shared = new_shared(); - let mut actions = new_actions(&runtime); - let mut registry = HostRegistry::default(); - - let outcome = - presets.apply_preset(&mut shared, &mut actions, &mut registry, Uuid::new_v4()); - - assert_eq!(outcome, PresetApplyOutcome::NotFound); - assert!(drain_commands(&mut cmd_rx).is_empty()); - } - #[test] fn dispatch_import_sends_host_command() { let runtime = Runtime::new().unwrap(); @@ -1345,7 +567,8 @@ mod tests { let (presets, _, mut host_cmd_rx) = new_presets(); let mut actions = new_actions(&runtime); let mut registry = HostRegistry::default(); - registry.presets.add_preset( + add_preset_with_default_state( + &mut registry, "Errors", vec![SearchFilter::plain("error").ignore_case(true)], vec![ @@ -1366,11 +589,19 @@ mod tests { assert_eq!(params.presets.len(), 1); assert_eq!(params.presets[0].name, "Errors"); assert_eq!( - params.presets[0].filters, + params.presets[0] + .filters + .iter() + .map(|entry| entry.filter.clone()) + .collect::>(), vec![SearchFilter::plain("error").ignore_case(true)] ); assert_eq!( - params.presets[0].search_values, + params.presets[0] + .search_values + .iter() + .map(|entry| entry.filter.clone()) + .collect::>(), vec![ SearchFilter::plain("duration=(\\d+)") .regex(true) @@ -1388,13 +619,18 @@ mod tests { let (mut presets, _, mut host_cmd_rx) = new_presets(); let mut actions = new_actions(&runtime); let mut registry = HostRegistry::default(); - let first_id = - registry - .presets - .add_preset("Errors", vec![SearchFilter::plain("error")], vec![]); - registry - .presets - .add_preset("Warnings", vec![SearchFilter::plain("warn")], vec![]); + let first_id = add_preset_with_default_state( + &mut registry, + "Errors", + vec![SearchFilter::plain("error")], + vec![], + ); + add_preset_with_default_state( + &mut registry, + "Warnings", + vec![SearchFilter::plain("warn")], + vec![], + ); let path = PathBuf::from("/tmp/export.json"); presets.start_export_mode(®istry); presets.toggle_export_selection(first_id); diff --git a/crates/app/src/session/ui/bottom_panel/presets/query.rs b/crates/app/src/session/ui/bottom_panel/presets/query.rs index 22fe8f9f50..24d4a26557 100644 --- a/crates/app/src/session/ui/bottom_panel/presets/query.rs +++ b/crates/app/src/session/ui/bottom_panel/presets/query.rs @@ -3,13 +3,25 @@ use rustc_hash::FxHashSet; use uuid::Uuid; -use crate::common::matcher::substring_matcher::SubstringMatcher; - -use super::{HostRegistry, Preset, PresetQueryState}; +use crate::{ + common::matcher::substring_matcher::SubstringMatcher, + host::ui::registry::{HostRegistry, presets::Preset}, +}; + +/// Cached name-filter state keyed by the preset catalog revision. +#[derive(Debug, Default)] +pub struct PresetQueryState { + /// User-entered preset name filter. + pub query: String, + matcher: SubstringMatcher, + // `None` means the query is empty and every preset stays visible. + matching_ids: Option>, + cached_revision: u64, +} impl PresetQueryState { /// Refreshes the cached visible-id set when the query or preset catalog changes. - pub(super) fn update_with_revision( + pub fn update_with_revision( &mut self, revision: u64, query_changed: bool, @@ -26,7 +38,7 @@ impl PresetQueryState { } /// Returns `true` when the preset should stay visible for the current query. - pub(super) fn matches(&self, preset_id: &Uuid) -> bool { + pub fn matches(&self, preset_id: &Uuid) -> bool { self.matching_ids .as_ref() .is_none_or(|matching_ids| matching_ids.contains(preset_id)) @@ -42,7 +54,7 @@ fn matches_preset_query(preset: &Preset, matcher: &mut SubstringMatcher) -> bool /// /// Returns `None` for an empty normalized query so the caller can treat that /// as "show everything" without storing a full-id snapshot. -pub(super) fn collect_matching_preset_ids( +pub fn collect_matching_preset_ids( matcher: &mut SubstringMatcher, registry: &HostRegistry, ) -> Option> { @@ -50,14 +62,14 @@ pub(super) fn collect_matching_preset_ids( return None; } - Some( - registry - .presets - .presets() - .iter() - .filter_map(|preset| matches_preset_query(preset, matcher).then_some(preset.id)) - .collect(), - ) + let matching_ids = registry + .presets + .presets() + .iter() + .filter_map(|preset| matches_preset_query(preset, matcher).then_some(preset.id)) + .collect(); + + Some(matching_ids) } #[cfg(test)] @@ -68,12 +80,12 @@ mod tests { use super::*; fn preset(name: &str) -> Preset { - Preset { - id: Uuid::new_v4(), - name: name.to_owned(), - filters: vec![SearchFilter::plain("filter")], - search_values: vec![], - } + Preset::with_default_state( + Uuid::new_v4(), + name.to_owned(), + vec![SearchFilter::plain("filter")], + vec![], + ) } fn build_matcher(query: &str) -> SubstringMatcher { @@ -82,6 +94,19 @@ mod tests { matcher } + fn add_preset_with_default_state( + registry: &mut HostRegistry, + name: &str, + filters: Vec, + search_values: Vec, + ) -> Uuid { + let preset = + Preset::with_default_state(Uuid::new_v4(), name.to_owned(), filters, search_values); + registry + .presets + .add_preset(preset.name, preset.filters, preset.search_values) + } + #[test] fn empty_query_matches_all() { let preset = preset("Errors"); @@ -100,7 +125,7 @@ mod tests { #[test] fn empty_query_cache_is_none() { let mut registry = HostRegistry::default(); - registry.presets.add_preset("Errors", vec![], vec![]); + add_preset_with_default_state(&mut registry, "Errors", vec![], vec![]); assert!(collect_matching_preset_ids(&mut build_matcher(" "), ®istry).is_none()); } @@ -108,8 +133,10 @@ mod tests { #[test] fn query_cache_collects_matching_ids() { let mut registry = HostRegistry::default(); - let matching_id = registry.presets.add_preset("Status Errors", vec![], vec![]); - let non_matching_id = registry.presets.add_preset("Warnings", vec![], vec![]); + let matching_id = + add_preset_with_default_state(&mut registry, "Status Errors", vec![], vec![]); + let non_matching_id = + add_preset_with_default_state(&mut registry, "Warnings", vec![], vec![]); let matching_ids = collect_matching_preset_ids(&mut build_matcher(" status "), ®istry).unwrap(); @@ -119,10 +146,12 @@ mod tests { #[test] fn query_ignores_items() { - let preset = Preset { - filters: vec![SearchFilter::plain("error")], - ..preset("Alpha") - }; + let preset = Preset::with_default_state( + Uuid::new_v4(), + "Alpha".to_owned(), + vec![SearchFilter::plain("error")], + vec![], + ); assert!(!matches_preset_query(&preset, &mut build_matcher("error"))); } @@ -134,14 +163,14 @@ mod tests { ..PresetQueryState::default() }; let mut registry = HostRegistry::default(); - let first_id = registry.presets.add_preset("warn", vec![], vec![]); + let first_id = add_preset_with_default_state(&mut registry, "warn", vec![], vec![]); state.update_with_revision(registry.presets.definitions_revision(), true, |matcher| { collect_matching_preset_ids(matcher, ®istry) }); assert!(state.matches(&first_id)); - let second_id = registry.presets.add_preset("warn later", vec![], vec![]); + let second_id = add_preset_with_default_state(&mut registry, "warn later", vec![], vec![]); assert!(!state.matches(&second_id)); diff --git a/crates/app/src/session/ui/bottom_panel/presets/render.rs b/crates/app/src/session/ui/bottom_panel/presets/render.rs index f571a6566f..af73e7f83e 100644 --- a/crates/app/src/session/ui/bottom_panel/presets/render.rs +++ b/crates/app/src/session/ui/bottom_panel/presets/render.rs @@ -1,19 +1,57 @@ //! Preset card rendering for browse and edit modes. use egui::{ - Align, Frame, Key, Layout, Margin, Response, RichText, ScrollArea, Sense, Sides, StrokeKind, - TextEdit, Ui, UiBuilder, vec2, + Align, Color32, Frame, Key, Layout, Margin, Response, RichText, ScrollArea, Sense, Sides, + StrokeKind, TextEdit, Ui, UiBuilder, vec2, }; +use uuid::Uuid; -use super::{ - HostRegistry, Preset, PresetAction, PresetBrowseSection, PresetItemRow, PresetsUI, - SearchFilter, card_metrics, icons, +use processor::search::filter::SearchFilter; + +use crate::{ + common::{phosphor::icons, ui::buttons}, + host::ui::registry::{ + HostRegistry, + presets::{Preset, PresetFilterEntry, PresetSearchValueEntry}, + }, }; -use crate::common::ui::buttons; + +use super::{PresetAction, PresetsUI}; + +mod card_metrics { + pub const PRESET_CARD_WIDTH: f32 = 280.0; + pub const PRESET_CARD_HEIGHT: f32 = 160.0; + pub const PRESET_CARD_INNER_MARGIN_X: i8 = 12; + pub const PRESET_CARD_INNER_MARGIN_Y: i8 = 8; + pub const PRESET_CARD_OUTER_MARGIN_Y: i8 = 4; + pub const PRESET_CARD_HEADER_GAP: f32 = 4.0; + pub const PRESET_EDIT_ITEM_ICON_SIZE: f32 = 12.0; + pub const PRESET_CARD_CONTENT_WIDTH: f32 = + PRESET_CARD_WIDTH - (PRESET_CARD_INNER_MARGIN_X as f32 * 2.0); + pub const PRESET_CARD_CONTENT_HEIGHT: f32 = PRESET_CARD_HEIGHT + - ((PRESET_CARD_INNER_MARGIN_Y as f32 + PRESET_CARD_OUTER_MARGIN_Y as f32) * 2.0); +} + +/// Render-time metadata for a single editable preset row. +#[derive(Debug, Clone, Copy)] +struct PresetItemRow<'a> { + label: &'a str, + enabled: bool, + color: Color32, + index: usize, + len: usize, +} + +/// Logical sections shared by preset browse and edit rendering. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PresetBrowseSection { + Filter, + SearchValue, +} impl PresetsUI { /// Renders a single fixed-size preset card and returns its container response. - pub(super) fn render_preset_card( + pub fn render_preset_card( &mut self, preset: &Preset, registry: &HostRegistry, @@ -22,74 +60,86 @@ impl PresetsUI { ) -> Response { let is_editing = self.is_editing(preset.id); let is_export_selected = self.is_selected_for_export(preset.id); - ui.allocate_ui_with_layout( - vec2( - card_metrics::PRESET_CARD_WIDTH, - card_metrics::PRESET_CARD_HEIGHT, - ), - Layout::top_down(Align::Min), - |ui| { - let visuals = ui.visuals(); - let mut frame = Frame::group(ui.style()) - .fill(visuals.faint_bg_color) - .inner_margin(Margin::symmetric( - card_metrics::PRESET_CARD_INNER_MARGIN_X, - card_metrics::PRESET_CARD_INNER_MARGIN_Y, - )) - .outer_margin(Margin::symmetric( - 0, - card_metrics::PRESET_CARD_OUTER_MARGIN_Y, - )); - if is_editing { - frame = frame - .fill(visuals.widgets.open.bg_fill) - .stroke(visuals.selection.stroke); - } else if is_export_selected { - frame = frame.stroke(visuals.selection.stroke); - } + let card_size = vec2( + card_metrics::PRESET_CARD_WIDTH, + card_metrics::PRESET_CARD_HEIGHT, + ); + ui.allocate_ui_with_layout(card_size, Layout::top_down(Align::Min), |ui| { + let visuals = ui.visuals(); + let mut frame = Frame::group(ui.style()) + .fill(visuals.faint_bg_color) + .inner_margin(Margin::symmetric( + card_metrics::PRESET_CARD_INNER_MARGIN_X, + card_metrics::PRESET_CARD_INNER_MARGIN_Y, + )) + .outer_margin(Margin::symmetric( + 0, + card_metrics::PRESET_CARD_OUTER_MARGIN_Y, + )); + if is_editing { + frame = frame + .fill(visuals.widgets.open.bg_fill) + .stroke(visuals.selection.stroke); + } else if is_export_selected { + frame = frame.stroke(visuals.selection.stroke); + } - frame.show(ui, |ui| { - // Lock the inner card size so long content cannot widen or - // stretch cards inside the wrapped layout. - ui.set_width(card_metrics::PRESET_CARD_CONTENT_WIDTH); - ui.set_height(card_metrics::PRESET_CARD_CONTENT_HEIGHT); - if is_editing { - self.render_edit_header(preset.id, ui, pending_action); - } else if self.is_exporting() { - self.render_export_header(preset, ui, pending_action); - } else { - self.render_browse_header(preset, ui, pending_action); + frame.show(ui, |ui| { + // Lock the inner card size so long content cannot widen or + // stretch cards inside the wrapped layout. + ui.set_width(card_metrics::PRESET_CARD_CONTENT_WIDTH); + ui.set_height(card_metrics::PRESET_CARD_CONTENT_HEIGHT); + if is_editing { + self.render_edit_header(preset.id, ui, pending_action); + if let Some(edit_state) = self + .edit_state + .as_ref() + .filter(|state| state.preset_id == preset.id) + { + let summary = entries_summary( + &edit_state.draft_filters, + &edit_state.draft_search_values, + ); + ui.label(RichText::new(summary).weak().size(11.0)); } + } else if self.is_exporting() { + self.render_export_header(preset, ui, pending_action); + let summary = entries_summary(&preset.filters, &preset.search_values); + ui.label(RichText::new(summary).weak().size(11.0)); + } else { + self.render_browse_header(preset, ui, pending_action); + let summary = entries_summary(&preset.filters, &preset.search_values); + ui.label(RichText::new(summary).weak().size(11.0)); + } - ui.add_space(card_metrics::PRESET_CARD_HEADER_GAP); - - ScrollArea::vertical() - .id_salt(("preset_card_body", preset.id)) - .auto_shrink(false) - .show(ui, |ui| { - Frame::NONE - .inner_margin(Margin { - left: 1, - right: 6, - top: 0, - bottom: 0, - }) - .show(ui, |ui| { - if is_editing { - self.render_editing_body( - preset.id, - registry, - ui, - pending_action, - ); - } else { - self.render_browse_body(preset, ui); - } - }); - }); - }); - }, - ) + ui.add_space(card_metrics::PRESET_CARD_HEADER_GAP); + + ScrollArea::vertical() + .id_salt(("preset_card_body", preset.id)) + .auto_shrink(false) + .show(ui, |ui| { + Frame::NONE + .inner_margin(Margin { + left: 1, + right: 6, + top: 0, + bottom: 0, + }) + .show(ui, |ui| { + if is_editing { + self.render_editing_body( + preset.id, + registry, + ui, + pending_action, + ); + } else { + self.render_browse_body(preset, ui); + } + }); + }); + }); + }) .response } @@ -109,7 +159,8 @@ impl PresetsUI { .on_hover_text("Include preset in export") .changed() { - *pending_action = Some(PresetAction::ToggleExportSelection(preset.id)); + let action = PresetAction::ToggleExportSelection(preset.id); + *pending_action = Some(action); } }); }); @@ -132,7 +183,8 @@ impl PresetsUI { .on_hover_text("Delete preset") .clicked() { - *pending_action = Some(PresetAction::Delete(preset.id)); + let action = PresetAction::Delete(preset.id); + *pending_action = Some(action); } if ui .add(buttons::bottom_panel_icon( @@ -150,7 +202,8 @@ impl PresetsUI { .on_hover_text("Apply preset") .clicked() { - *pending_action = Some(PresetAction::Apply(preset.id)); + let action = PresetAction::Apply(preset.id); + *pending_action = Some(action); } }); }); @@ -158,18 +211,14 @@ impl PresetsUI { /// Renders the read-only preset contents below the card header. fn render_browse_body(&self, preset: &Preset, ui: &mut Ui) { - self.render_browse_section(PresetBrowseSection::Filter, &preset.filters, ui); + self.render_browse_filters(&preset.filters, ui); ui.separator(); - self.render_browse_section(PresetBrowseSection::SearchValue, &preset.search_values, ui); + self.render_browse_search_values(&preset.search_values, ui); } - /// Renders one browse section and its current item list. - fn render_browse_section( - &self, - section: PresetBrowseSection, - items: &[SearchFilter], - ui: &mut Ui, - ) { + /// Renders the browse filter list. + fn render_browse_filters(&self, items: &[PresetFilterEntry], ui: &mut Ui) { + let section = PresetBrowseSection::Filter; ui.label(section_title(section.title(), items.len())); if items.is_empty() { @@ -178,37 +227,68 @@ impl PresetsUI { } for item in items { - match section { - PresetBrowseSection::Filter => self.render_filter_row(ui, item), - PresetBrowseSection::SearchValue => self.render_search_value_row(ui, item), - } + self.render_filter_row(ui, item); } } - /// Renders a browse row for a filter, including its semantic flags. - fn render_filter_row(&self, ui: &mut Ui, filter: &SearchFilter) { + /// Renders the browse chart/search-value list. + fn render_browse_search_values(&self, items: &[PresetSearchValueEntry], ui: &mut Ui) { + let section = PresetBrowseSection::SearchValue; + ui.label(section_title(section.title(), items.len())); + + if items.is_empty() { + ui.label(RichText::new(section.empty_text()).weak()); + return; + } + + for item in items { + self.render_search_value_row(ui, item); + } + } + + /// Renders a browse row for a filter, including its matching flags. + fn render_filter_row(&self, ui: &mut Ui, item: &PresetFilterEntry) { Self::item_frame(ui).show(ui, |ui| { Sides::new().shrink_left().truncate().show( ui, |ui| { - ui.label(filter.value.as_str()); + Self::render_readonly_checkbox(ui, item.enabled); + Self::render_color_swatch(ui, item.colors.bg); + Self::render_item_label(ui, item.filter.value.as_str(), item.enabled); }, |ui| { - self.render_filter_flag(ui, icons::regular::ASTERISK, filter.is_regex()); - self.render_filter_flag(ui, icons::regular::TEXT_T, filter.is_word()); - self.render_filter_flag(ui, icons::regular::TEXT_AA, !filter.is_ignore_case()); + self.render_filter_flag( + ui, + icons::regular::ASTERISK, + item.filter.is_regex(), + item.enabled, + ); + self.render_filter_flag( + ui, + icons::regular::TEXT_T, + item.filter.is_word(), + item.enabled, + ); + self.render_filter_flag( + ui, + icons::regular::TEXT_AA, + !item.filter.is_ignore_case(), + item.enabled, + ); }, ); }); } /// Renders a browse row for a chart/search-value entry. - fn render_search_value_row(&self, ui: &mut Ui, filter: &SearchFilter) { + fn render_search_value_row(&self, ui: &mut Ui, item: &PresetSearchValueEntry) { Self::item_frame(ui).show(ui, |ui| { Sides::new().shrink_left().truncate().show( ui, |ui| { - ui.label(filter.value.as_str()); + Self::render_readonly_checkbox(ui, item.enabled); + Self::render_color_swatch(ui, item.color); + Self::render_item_label(ui, item.filter.value.as_str(), item.enabled); }, |_ui| {}, ); @@ -223,8 +303,8 @@ impl PresetsUI { } /// Renders a filter flag icon with active or weak emphasis. - fn render_filter_flag(&self, ui: &mut Ui, icon: &str, active: bool) { - let color = if active { + fn render_filter_flag(&self, ui: &mut Ui, icon: &str, active: bool, enabled: bool) { + let color = if active && enabled { ui.visuals().text_color() } else { ui.visuals().weak_text_color() @@ -232,10 +312,58 @@ impl PresetsUI { ui.label(RichText::new(icon).size(12.0).color(color)); } + /// Renders the saved enabled state without allowing browse-mode changes. + fn render_readonly_checkbox(ui: &mut Ui, enabled: bool) { + let mut value = enabled; + let response = ui + .add_enabled_ui(false, |ui| ui.checkbox(&mut value, "")) + .inner; + response.on_disabled_hover_ui(|ui| { + ui.set_max_width(ui.spacing().tooltip_width); + if enabled { + ui.label("Saved as enabled"); + } else { + ui.label("Saved as disabled"); + } + }); + } + + /// Renders an editable enabled-state checkbox and returns whether it changed. + fn render_enabled_checkbox(ui: &mut Ui, enabled: bool) -> bool { + let mut value = enabled; + ui.checkbox(&mut value, "") + .on_hover_ui(|ui| { + ui.set_max_width(ui.spacing().tooltip_width); + if enabled { + ui.label("Save this row as disabled"); + } else { + ui.label("Save this row as enabled"); + } + }) + .changed() + } + + /// Renders a row label with disabled rows visually muted. + fn render_item_label(ui: &mut Ui, label: &str, enabled: bool) { + if enabled { + ui.label(label); + } else { + ui.label(RichText::new(label).weak()); + } + } + + /// Renders the compact row color swatch. + fn render_color_swatch(ui: &mut Ui, color: Color32) { + const ITEM_SWATCH_SIZE: egui::Vec2 = vec2(10.0, 20.0); + + let (response, painter) = ui.allocate_painter(ITEM_SWATCH_SIZE, Sense::hover()); + painter.rect_filled(response.rect, 2.0, color); + } + /// Renders the editable filter and chart lists for the active draft. fn render_editing_body( &mut self, - preset_id: uuid::Uuid, + preset_id: Uuid, registry: &HostRegistry, ui: &mut Ui, pending_action: &mut Option, @@ -264,7 +392,7 @@ impl PresetsUI { /// Renders the edit-mode header, including scoped save and cancel shortcuts. fn render_edit_header( &mut self, - preset_id: uuid::Uuid, + preset_id: Uuid, ui: &mut Ui, pending_action: &mut Option, ) { @@ -283,68 +411,67 @@ impl PresetsUI { draft_name_has_focus && input.consume_key(egui::Modifiers::NONE, Key::Enter) }); - ui.allocate_ui_with_layout( - vec2(ui.available_width(), 16.0), - Layout::right_to_left(Align::Center), - |ui| { - if ui - .add(buttons::bottom_panel_icon( - RichText::new(icons::regular::CHECK).size(14.0), - )) - .on_hover_text("Save preset") - .clicked() - || enter_pressed - { - *pending_action = Some(PresetAction::SaveEdit(preset_id)); - } - - let mut cancel_edit = ui - .add(buttons::bottom_panel_icon( - RichText::new(icons::regular::X).size(14.0), - )) - .on_hover_text("Cancel edit") - .clicked(); - - ui.with_layout(Layout::left_to_right(Align::Center), |ui| { - let mut text_edit = TextEdit::singleline(&mut edit_state.draft_name) - .id(draft_name_id) - .desired_width(f32::INFINITY) - .show(ui); - - // Input text will lose focus directly on pressing escape which can - // be used as indicator of escape pressed while input text in focus. - let escape_pressed = text_edit.response.lost_focus() - && text_edit - .response - .ctx - .input(|input| input.key_pressed(Key::Escape)); - - if escape_pressed { - cancel_edit = true; - } + let header_size = vec2(ui.available_width(), 16.0); + ui.allocate_ui_with_layout(header_size, Layout::right_to_left(Align::Center), |ui| { + if ui + .add(buttons::bottom_panel_icon( + RichText::new(icons::regular::CHECK).size(14.0), + )) + .on_hover_text("Save preset") + .clicked() + || enter_pressed + { + let action = PresetAction::SaveEdit(preset_id); + *pending_action = Some(action); + } - if edit_state.first_render_frame { - // Entering edit mode should focus the draft name once, - // without re-stealing focus on later frames. - text_edit.state.cursor.set_char_range(None); - text_edit.state.store(ui.ctx(), text_edit.response.id); - text_edit.response.request_focus(); - edit_state.first_render_frame = false; - } - }); + let mut cancel_edit = ui + .add(buttons::bottom_panel_icon( + RichText::new(icons::regular::X).size(14.0), + )) + .on_hover_text("Cancel edit") + .clicked(); + + ui.with_layout(Layout::left_to_right(Align::Center), |ui| { + let mut text_edit = TextEdit::singleline(&mut edit_state.draft_name) + .id(draft_name_id) + .desired_width(f32::INFINITY) + .show(ui); + + // Input text will lose focus directly on pressing escape which can + // be used as indicator of escape pressed while input text in focus. + let escape_pressed = text_edit.response.lost_focus() + && text_edit + .response + .ctx + .input(|input| input.key_pressed(Key::Escape)); + + if escape_pressed { + cancel_edit = true; + } - if cancel_edit { - *pending_action = Some(PresetAction::CancelEdit(preset_id)); + if edit_state.first_render_frame { + // Entering edit mode should focus the draft name once, + // without re-stealing focus on later frames. + text_edit.state.cursor.set_char_range(None); + text_edit.state.store(ui.ctx(), text_edit.response.id); + text_edit.response.request_focus(); + edit_state.first_render_frame = false; } - }, - ); + }); + + if cancel_edit { + let action = PresetAction::CancelEdit(preset_id); + *pending_action = Some(action); + } + }); } /// Renders the editable filter section and its add menu. fn render_edit_filters_section( &self, - preset_id: uuid::Uuid, - draft_filters: &[SearchFilter], + preset_id: Uuid, + draft_filters: &[PresetFilterEntry], registry: &HostRegistry, ui: &mut Ui, pending_action: &mut Option, @@ -380,7 +507,9 @@ impl PresetsUI { } for definition in filters { - let already_added = draft_filters.contains(&definition.filter); + let already_added = draft_filters + .iter() + .any(|entry| entry.filter == definition.filter); let response = ui .add_enabled_ui(!already_added, |ui| { render_filter_picker_button(ui, &definition.filter) @@ -388,10 +517,9 @@ impl PresetsUI { .inner .on_disabled_hover_text("Already in preset"); if response.clicked() { - *pending_action = Some(PresetAction::AddFilter( - preset_id, - definition.filter.clone(), - )); + let action = + PresetAction::AddFilter(preset_id, definition.filter.clone()); + *pending_action = Some(action); ui.close(); } } @@ -407,14 +535,17 @@ impl PresetsUI { return; } - for (index, filter) in draft_filters.iter().enumerate() { + for (index, entry) in draft_filters.iter().enumerate() { self.render_edit_item_row( PresetItemRow { - label: filter.value.as_str(), + label: entry.filter.value.as_str(), + enabled: entry.enabled, + color: entry.colors.bg, index, len: draft_filters.len(), }, ui, + |row| PresetAction::ToggleFilterEnabled(preset_id, row), |from, to| PresetAction::MoveFilter(preset_id, from, to), |row| PresetAction::RemoveFilter(preset_id, row), pending_action, @@ -425,8 +556,8 @@ impl PresetsUI { /// Renders the editable chart section and its add menu. fn render_edit_search_values_section( &self, - preset_id: uuid::Uuid, - draft_search_values: &[SearchFilter], + preset_id: Uuid, + draft_search_values: &[PresetSearchValueEntry], registry: &HostRegistry, ui: &mut Ui, pending_action: &mut Option, @@ -452,7 +583,9 @@ impl PresetsUI { } for definition in values { - let already_added = draft_search_values.contains(&definition.filter); + let already_added = draft_search_values + .iter() + .any(|entry| entry.filter == definition.filter); let response = ui .add_enabled( !already_added, @@ -460,10 +593,11 @@ impl PresetsUI { ) .on_disabled_hover_text("Already in preset"); if response.clicked() { - *pending_action = Some(PresetAction::AddSearchValue( + let action = PresetAction::AddSearchValue( preset_id, definition.filter.clone(), - )); + ); + *pending_action = Some(action); ui.close(); } } @@ -479,14 +613,17 @@ impl PresetsUI { return; } - for (index, filter) in draft_search_values.iter().enumerate() { + for (index, entry) in draft_search_values.iter().enumerate() { self.render_edit_item_row( PresetItemRow { - label: filter.value.as_str(), + label: entry.filter.value.as_str(), + enabled: entry.enabled, + color: entry.color, index, len: draft_search_values.len(), }, ui, + |row| PresetAction::ToggleSearchValueEnabled(preset_id, row), |from, to| PresetAction::MoveSearchValue(preset_id, from, to), |row| PresetAction::RemoveSearchValue(preset_id, row), pending_action, @@ -495,23 +632,32 @@ impl PresetsUI { } /// Renders one editable preset row and emits deferred mutations for its controls. - fn render_edit_item_row( + fn render_edit_item_row( &self, row: PresetItemRow<'_>, ui: &mut Ui, + toggle_action: FToggle, move_action: FMove, remove_action: FRemove, pending_action: &mut Option, ) where + FToggle: Fn(usize) -> PresetAction, FMove: Fn(usize, usize) -> PresetAction, FRemove: Fn(usize) -> PresetAction, { - Sides::new().shrink_left().truncate().show( + let (toggle, controls) = Sides::new().shrink_left().truncate().show( ui, |ui| { - ui.label(row.label); + let mut action = None; + if Self::render_enabled_checkbox(ui, row.enabled) { + action = Some(toggle_action(row.index)); + } + Self::render_color_swatch(ui, row.color); + Self::render_item_label(ui, row.label, row.enabled); + action }, |ui| { + let mut action = None; // Mutations are deferred until after rendering so the // immediate-mode traversal does not edit the active list in place. if ui @@ -522,7 +668,7 @@ impl PresetsUI { .on_hover_text("Remove from preset") .clicked() { - *pending_action = Some(remove_action(row.index)); + action = Some(remove_action(row.index)); } let can_move_down = row.index + 1 < row.len; @@ -537,7 +683,7 @@ impl PresetsUI { .on_hover_text("Move down") .clicked() { - *pending_action = Some(move_action(row.index, row.index + 1)); + action = Some(move_action(row.index, row.index + 1)); } let can_move_up = row.index > 0; @@ -552,10 +698,15 @@ impl PresetsUI { .on_hover_text("Move up") .clicked() { - *pending_action = Some(move_action(row.index, row.index - 1)); + action = Some(move_action(row.index, row.index - 1)); } + action }, ); + + if let Some(action) = toggle.or(controls) { + *pending_action = Some(action); + } } } @@ -582,7 +733,29 @@ fn section_title(title: &str, count: usize) -> String { format!("{title} ({count})") } -fn render_filter_picker_button(ui: &mut Ui, filter: &SearchFilter) -> egui::Response { +/// Formats the compact card summary for filters, charts, and disabled rows. +fn entries_summary( + filters: &[PresetFilterEntry], + search_values: &[PresetSearchValueEntry], +) -> String { + let disabled_count = filters.iter().filter(|entry| !entry.enabled).count() + + search_values.iter().filter(|entry| !entry.enabled).count(); + + let filters_count = filters.len(); + let filters_label = if filters_count == 1 { + "filter" + } else { + "filters" + }; + let charts_count = search_values.len(); + let charts_label = if charts_count == 1 { "chart" } else { "charts" }; + + format!( + "{filters_count} {filters_label} · {charts_count} {charts_label} · {disabled_count} disabled" + ) +} + +fn render_filter_picker_button(ui: &mut Ui, filter: &SearchFilter) -> Response { let desired_size = vec2(ui.available_width(), ui.spacing().interact_size.y); let (_, response) = ui.allocate_exact_size(desired_size, Sense::click()); let button_padding = ui.spacing().button_padding; @@ -651,14 +824,3 @@ fn render_filter_picker_button(ui: &mut Ui, filter: &SearchFilter) -> egui::Resp response } - -#[cfg(test)] -mod tests { - use super::section_title; - - #[test] - fn section_title_shows_count() { - assert_eq!(section_title("Filters", 3), "Filters (3)"); - assert_eq!(section_title("Charts", 0), "Charts (0)"); - } -} diff --git a/crates/app/src/session/ui/shared/mod.rs b/crates/app/src/session/ui/shared/mod.rs index 54df39520c..5a9fefa89c 100644 --- a/crates/app/src/session/ui/shared/mod.rs +++ b/crates/app/src/session/ui/shared/mod.rs @@ -1,7 +1,12 @@ use std::rc::Rc; +use egui::Color32; + use crate::{ - host::ui::{UiActions, registry::filters::FilterRegistry}, + host::{ + common::colors::ColorPair, + ui::{UiActions, registry::filters::FilterRegistry}, + }, session::{ command::SessionCommand, types::{ObserveOperation, OperationPhase}, @@ -364,6 +369,23 @@ impl SessionShared { changed } + /// Applies full filter row state and tracks recent-session dirtiness. + pub fn set_filter_entry_state( + &mut self, + registry: &mut FilterRegistry, + filter_id: Uuid, + enabled: bool, + colors: ColorPair, + ) -> bool { + let changed = self + .filters + .set_filter_entry_state(registry, filter_id, enabled, colors); + if changed { + self.bump_recent_revision(); + } + changed + } + /// Removes a filter from this session and tracks recent-session dirtiness. pub fn unapply_filter(&mut self, registry: &mut FilterRegistry, filter_id: &Uuid) -> bool { let changed = self.filters.unapply_filter(registry, filter_id); @@ -407,6 +429,23 @@ impl SessionShared { changed } + /// Applies full search-value row state and tracks recent-session dirtiness. + pub fn set_search_value_entry_state( + &mut self, + registry: &mut FilterRegistry, + value_id: Uuid, + enabled: bool, + color: Color32, + ) -> bool { + let changed = self + .filters + .set_search_value_entry_state(registry, value_id, enabled, color); + if changed { + self.bump_recent_revision(); + } + changed + } + /// Removes a search value from this session and tracks recent-session dirtiness. pub fn unapply_search_value(&mut self, registry: &mut FilterRegistry, value_id: &Uuid) -> bool { let changed = self.filters.unapply_search_value(registry, value_id); diff --git a/crates/app/src/session/ui/shared/searching/filters.rs b/crates/app/src/session/ui/shared/searching/filters.rs index 3419c5797e..0e6a4a02b0 100644 --- a/crates/app/src/session/ui/shared/searching/filters.rs +++ b/crates/app/src/session/ui/shared/searching/filters.rs @@ -258,6 +258,54 @@ impl FiltersState { true } + /// Adds or updates a filter row with explicit enabled state and colors. + pub fn set_filter_entry_state( + &mut self, + registry: &mut FilterRegistry, + id: Uuid, + enabled: bool, + colors: ColorPair, + ) -> bool { + if let Some(item) = self.filter_entries.iter_mut().find(|item| item.id == id) { + let changed = item.enabled != enabled || item.colors != colors; + item.enabled = enabled; + item.colors = colors; + return changed; + } + + self.filter_entries + .push(AppliedFilterState::new(id, enabled, colors)); + registry.apply_filter_to_session(id, self.session_id); + + true + } + + /// Adds or updates a search-value row with explicit enabled state and color. + pub fn set_search_value_entry_state( + &mut self, + registry: &mut FilterRegistry, + id: Uuid, + enabled: bool, + color: Color32, + ) -> bool { + if let Some(item) = self + .search_value_entries + .iter_mut() + .find(|item| item.id == id) + { + let changed = item.enabled != enabled || item.color != color; + item.enabled = enabled; + item.color = color; + return changed; + } + + self.search_value_entries + .push(AppliedSearchValueState::new(id, enabled, color)); + registry.apply_search_value_to_session(id, self.session_id); + + true + } + /// Updates the enabled flag for an existing filter and reports /// whether it changed. pub fn set_filter_enabled(&mut self, id: &Uuid, enabled: bool) -> bool {