From 98114f58a16a50c071d2d0a2c17a1e67e3e08c83 Mon Sep 17 00:00:00 2001 From: Ammar Abou Zor Date: Sun, 7 Jun 2026 22:33:14 +0200 Subject: [PATCH 1/7] Include Colors & Active/Inactive in Presets. * Add colors and active state to presets including changing data models. * Introduce new version from presets with support for colors and state. * Convert V1 to V2 while import using default colors --- crates/app/src/host/message.rs | 26 +- crates/app/src/host/service/mod.rs | 13 +- crates/app/src/host/service/presets_io.rs | 894 ------------------ .../app/src/host/service/presets_io/legacy.rs | 504 ++++++++++ crates/app/src/host/service/presets_io/mod.rs | 235 +++++ crates/app/src/host/service/presets_io/v1.rs | 168 ++++ crates/app/src/host/service/presets_io/v2.rs | 411 ++++++++ crates/app/src/host/ui/registry/mod.rs | 8 +- crates/app/src/host/ui/registry/presets.rs | 498 +++++++--- crates/app/src/host/ui/state/presets.rs | 76 +- .../session/ui/bottom_panel/presets/mod.rs | 241 +++-- .../session/ui/bottom_panel/presets/query.rs | 69 +- .../session/ui/bottom_panel/presets/render.rs | 335 +++---- 13 files changed, 2093 insertions(+), 1385 deletions(-) delete mode 100644 crates/app/src/host/service/presets_io.rs create mode 100644 crates/app/src/host/service/presets_io/legacy.rs create mode 100644 crates/app/src/host/service/presets_io/mod.rs create mode 100644 crates/app/src/host/service/presets_io/v1.rs create mode 100644 crates/app/src/host/service/presets_io/v2.rs 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..662fcd6786 --- /dev/null +++ b/crates/app/src/host/service/presets_io/legacy.rs @@ -0,0 +1,504 @@ +//! 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 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::ui::registry::presets::Preset, +}; + +use super::{ImportWarning, LegacyEntryKind}; + +/// 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: 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 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(_) => { + 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)) +} + +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) { + 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() { + 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::with_default_state(Uuid::new_v4(), name, filters, search_values); + let outcome = LegacyCollectionOutcome::Preset { preset, warnings }; + + Ok(outcome) +} + +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(|| 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"))); + } + + 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(|| 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"))); + } + + Ok(filter) +} + +#[cfg(test)] +mod tests { + use std::{fs, path::PathBuf}; + + use processor::search::filter::SearchFilter; + + use crate::host::{ + message::ImportFormat, + service::presets_io::{ImportWarning, LegacyEntryKind, import_named_presets}, + }; + + fn regex(value: &str) -> SearchFilter { + SearchFilter::plain(value).regex(true).ignore_case(true) + } + + fn search_value_definitions( + preset: &crate::host::ui::registry::presets::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") + } + + #[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_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: &[crate::host::ui::registry::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: &crate::host::ui::registry::presets::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..8d6cbb2fae --- /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_eq!(parsed.presets[0].filters[0].enabled, false); + assert_eq!( + parsed.presets[0].filters[0].colors, + source[0].filters[0].colors + ); + assert_eq!(parsed.presets[0].search_values[0].enabled, false); + 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/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.rs b/crates/app/src/host/ui/registry/presets.rs index 21b2e1f4ae..1e5ececdf0 100644 --- a/crates/app/src/host/ui/registry/presets.rs +++ b/crates/app/src/host/ui/registry/presets.rs @@ -1,10 +1,19 @@ +//! Host registry storage for named preset snapshots. + use std::borrow::Cow; -use crate::{host::ui::registry::filters::FilterRegistry, session::ui::SessionShared}; +use egui::Color32; use processor::search::filter::SearchFilter; - use uuid::Uuid; +use crate::{ + host::{ + common::colors::{self, ColorPair}, + ui::registry::filters::FilterRegistry, + }, + session::ui::SessionShared, +}; + /// Host-level registry for named preset snapshots captured from session filters and charts. #[derive(Debug, Default, Clone)] pub struct PresetRegistry { @@ -13,20 +22,49 @@ pub struct PresetRegistry { definitions_revision: u64, } -/// Preset definition with copied semantic content. +/// 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, - pub filters: Vec, - pub search_values: Vec, + /// 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, } /// 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 +74,90 @@ 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 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, + } + } +} + impl PresetRegistry { + /// Returns stored presets in display order. pub fn presets(&self) -> &[Preset] { &self.presets } @@ -50,37 +168,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,6 +250,7 @@ impl PresetRegistry { PresetImportSummary { renamed_items } } + /// Captures the current session-applied filters and charts as a preset. pub fn add_preset_from_session( &mut self, shared: &SessionShared, @@ -131,26 +260,33 @@ impl PresetRegistry { .filters .filter_entries .iter() - .filter_map(|item| registry.get_filter(&item.id)) - .map(|def| def.filter.clone()) + .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| def.filter.clone()) + .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) } + /// 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 +294,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 +318,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; @@ -211,6 +355,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 +446,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 +459,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 +475,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 +487,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 +499,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 +540,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 +551,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 +562,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 +574,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 +592,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 +610,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 +620,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 +632,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 +660,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 +685,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 +699,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 +726,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 +734,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 +744,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 +756,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 +769,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 +779,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 +791,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 +820,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,7 +831,8 @@ 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); @@ -670,39 +861,58 @@ mod tests { .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"); - 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(), - ] - ); + 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/state/presets.rs b/crates/app/src/host/ui/state/presets.rs index 8f4e649932..8428814653 100644 --- a/crates/app/src/host/ui/state/presets.rs +++ b/crates/app/src/host/ui/state/presets.rs @@ -1,13 +1,20 @@ +//! Host UI handlers for preset import and export messages. + use std::{ 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 +23,7 @@ impl HostState { let PresetsImported { path, presets, - used_legacy_format, + format, } = imported; let imported_count = presets.len(); @@ -24,11 +31,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,13 +47,12 @@ 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 source = match format { + ImportFormat::Version1 | ImportFormat::Version2 => "preset file", + ImportFormat::Legacy => "legacy preset file", }; let mut message = if imported_count == 0 { @@ -72,6 +79,15 @@ fn format_preset_import_report( ); } + match format { + ImportFormat::Version1 => { + message.push_str( + " V1 preset files do not store row colors or enabled state, so defaults were applied. Re-export these presets to preserve that state in future imports.", + ); + } + ImportFormat::Version2 | ImportFormat::Legacy => {} + } + message } @@ -86,47 +102,3 @@ fn format_preset_export_report(path: &Path, count: usize) -> String { fn pluralize<'a>(count: usize, singular: &'a str, plural: &'a str) -> &'a str { if count == 1 { singular } else { plural } } - -#[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'."); - } -} 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..29d42160f9 100644 --- a/crates/app/src/session/ui/bottom_panel/presets/mod.rs +++ b/crates/app/src/session/ui/bottom_panel/presets/mod.rs @@ -1,3 +1,5 @@ +//! Preset tab state and interactions for the session bottom panel. + use egui::{Align, Frame, Layout, Margin, RichText, ScrollArea, Ui, UiBuilder, Widget, vec2}; use processor::search::filter::SearchFilter; use rustc_hash::FxHashSet; @@ -19,8 +21,8 @@ use crate::{ actions::{FileDialogFilter, FileDialogOptions}, registry::{ HostRegistry, - filters::{FilterDefinition, SearchValueDefinition}, - presets::{Preset, PresetUpdateOutcome}, + filters::{FilterDefinition, FilterRegistry, SearchValueDefinition}, + presets::{Preset, PresetFilterEntry, PresetSearchValueEntry, PresetUpdateOutcome}, }, }, }, @@ -73,7 +75,7 @@ struct PresetQueryState { query: String, matcher: SubstringMatcher, // `None` means the query is empty and every preset stays visible. - matching_ids: Option>, + matching_ids: Option>, cached_revision: u64, } @@ -97,8 +99,8 @@ enum PresetBrowseSection { struct PresetEditState { preset_id: Uuid, draft_name: String, - draft_filters: Vec, - draft_search_values: Vec, + draft_filters: Vec, + draft_search_values: Vec, // Used to autofocus the draft name exactly once when entering edit mode. first_render_frame: bool, } @@ -120,21 +122,33 @@ enum PresetApplyOutcome { /// 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), + /// 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 +161,7 @@ impl PresetsUI { } } + /// Renders the preset panel and processes deferred UI actions. pub fn render_content( &mut self, shared: &mut SessionShared, @@ -626,11 +641,17 @@ impl PresetsUI { 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) { + if edit_state.preset_id != preset_id + || edit_state + .draft_filters + .iter() + .any(|entry| entry.filter == filter) + { return false; } - edit_state.draft_filters.push(filter); + let entry = PresetFilterEntry::with_next_color(filter, &edit_state.draft_filters); + edit_state.draft_filters.push(entry); true } @@ -639,12 +660,17 @@ impl PresetsUI { return false; }; if edit_state.preset_id != preset_id - || edit_state.draft_search_values.contains(&search_value) + || edit_state + .draft_search_values + .iter() + .any(|entry| entry.filter == search_value) { return false; } - edit_state.draft_search_values.push(search_value); + let entry = + PresetSearchValueEntry::with_next_color(search_value, &edit_state.draft_search_values); + edit_state.draft_search_values.push(entry); true } @@ -730,11 +756,16 @@ impl PresetsUI { return PresetApplyOutcome::NotFound; }; - // Materialize preset semantics through the normal registry/session path. + // Materialize preset filters and charts 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 { + for entry in filters { + let PresetFilterEntry { + filter, + enabled: _enabled, + colors: _colors, + } = entry; let filter_id = registry.filters.add_filter(FilterDefinition::new(filter)); if shared.filters.is_filter_applied(&filter_id) { continue; @@ -745,10 +776,15 @@ impl PresetsUI { } let mut changed_search_values = false; - for search_value in search_values { + for entry in search_values { + let PresetSearchValueEntry { + filter, + enabled: _enabled, + color: _color, + } = entry; let value_id = registry .filters - .add_search_value(SearchValueDefinition::new(search_value)); + .add_search_value(SearchValueDefinition::new(filter)); if shared.filters.is_search_value_applied(&value_id) { continue; } @@ -775,7 +811,7 @@ impl PresetsUI { &self, shared: &mut SessionShared, actions: &mut UiActions, - registry: &crate::host::ui::registry::filters::FilterRegistry, + registry: &FilterRegistry, target: SearchSyncTarget, ) { // Preset apply mutates session state first, then issues the same explicit @@ -868,20 +904,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,6 +919,28 @@ mod tests { id } + 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) + } + fn drain_commands(cmd_rx: &mut mpsc::Receiver) -> Vec { let mut commands = Vec::new(); while let Ok(command) = cmd_rx.try_recv() { @@ -947,8 +999,8 @@ mod tests { 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![]); + 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); @@ -962,8 +1014,8 @@ mod tests { 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![]); + 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)); @@ -978,9 +1030,10 @@ mod tests { 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![]); + 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( @@ -1001,8 +1054,8 @@ mod tests { 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![]); + 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); @@ -1017,7 +1070,7 @@ mod tests { 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![]); + let first_id = add_preset_with_default_state(&mut registry, "Errors", vec![], vec![]); presets.start_export_mode(®istry); presets.clear_export_selection(); @@ -1030,7 +1083,7 @@ mod tests { 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)); @@ -1042,8 +1095,8 @@ mod tests { 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![]); + 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(); @@ -1058,7 +1111,8 @@ mod tests { fn move_filter_repositions_item() { let (mut presets, _, _) = new_presets(); let mut registry = HostRegistry::default(); - let preset_id = registry.presets.add_preset( + let preset_id = add_preset_with_default_state( + &mut registry, "first", vec![ SearchFilter::plain("one"), @@ -1077,7 +1131,7 @@ mod tests { edit_state .draft_filters .iter() - .map(|filter| filter.value.as_str()) + .map(|entry| entry.filter.value.as_str()) .collect::>(), vec!["one", "three", "four", "two"] ); @@ -1087,10 +1141,12 @@ mod tests { 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")]); + 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(); @@ -1108,25 +1164,26 @@ mod tests { fn save_commits_draft() { let (mut presets, _, _) = new_presets(); let mut registry = HostRegistry::default(); - let first_id = registry.presets.add_preset( + let first_id = add_preset_with_default_state( + &mut registry, "first", vec![SearchFilter::plain("one").ignore_case(true)], vec![], ); - registry.presets.add_preset("taken", vec![], vec![]); - registry.presets.add_preset("taken_2", vec![], 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 = vec![ + edit_state.draft_filters = filter_entries(vec![ SearchFilter::plain("warn").ignore_case(true), SearchFilter::plain("error").ignore_case(true), - ]; - edit_state.draft_search_values = vec![ + ]); + edit_state.draft_search_values = search_value_entries(vec![ SearchFilter::plain("duration=(\\d+)") .regex(true) .ignore_case(true), - ]; + ]); presets.save_edit(&mut registry, first_id); @@ -1136,7 +1193,7 @@ mod tests { preset .filters .iter() - .map(|filter| filter.value.clone()) + .map(|entry| entry.filter.value.clone()) .collect::>(), vec!["warn".to_owned(), "error".to_owned()] ); @@ -1144,7 +1201,7 @@ mod tests { preset .search_values .iter() - .map(|filter| filter.value.clone()) + .map(|entry| entry.filter.value.clone()) .collect::>(), vec!["duration=(\\d+)".to_owned()] ); @@ -1169,36 +1226,23 @@ mod tests { .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( + let preset_filter = registry + .filters + .get_filter(&filter_id) + .unwrap() + .filter + .clone(); + let preset_search_value = registry + .filters + .get_search_value(&value_id) + .unwrap() + .filter + .clone(); + let preset_id = add_preset_with_default_state( + &mut registry, "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(), - ], + vec![preset_filter.clone(), preset_filter], + vec![preset_search_value.clone(), preset_search_value], ); let outcome = presets.apply_preset(&mut shared, &mut actions, &mut registry, preset_id); @@ -1229,7 +1273,8 @@ mod tests { 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( + let preset_id = add_preset_with_default_state( + &mut registry, "test", vec![ SearchFilter::plain("existing").ignore_case(true), @@ -1345,7 +1390,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 +1412,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 +1442,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..bcb385e7bb 100644 --- a/crates/app/src/session/ui/bottom_panel/presets/query.rs +++ b/crates/app/src/session/ui/bottom_panel/presets/query.rs @@ -9,7 +9,7 @@ use super::{HostRegistry, Preset, PresetQueryState}; 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 +26,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 +42,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 +50,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 +68,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 +82,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 +113,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 +121,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 +134,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 +151,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..1d39f4568d 100644 --- a/crates/app/src/session/ui/bottom_panel/presets/render.rs +++ b/crates/app/src/session/ui/bottom_panel/presets/render.rs @@ -4,16 +4,17 @@ use egui::{ Align, 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, + HostRegistry, Preset, PresetAction, PresetBrowseSection, PresetFilterEntry, PresetItemRow, + PresetSearchValueEntry, PresetsUI, SearchFilter, card_metrics, icons, }; use crate::common::ui::buttons; 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 +23,71 @@ 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, - )); + 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 { - frame = frame - .fill(visuals.widgets.open.bg_fill) - .stroke(visuals.selection.stroke); - } else if is_export_selected { - frame = frame.stroke(visuals.selection.stroke); + 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); - } else if self.is_exporting() { - self.render_export_header(preset, ui, pending_action); - } else { - self.render_browse_header(preset, ui, pending_action); - } - - 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 +107,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 +131,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 +150,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 +159,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,14 +175,26 @@ 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.filter); + } + } + + /// 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.filter); } } - /// Renders a browse row for a filter, including its semantic flags. + /// Renders a browse row for a filter, including its matching flags. fn render_filter_row(&self, ui: &mut Ui, filter: &SearchFilter) { Self::item_frame(ui).show(ui, |ui| { Sides::new().shrink_left().truncate().show( @@ -235,7 +244,7 @@ impl PresetsUI { /// 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 +273,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 +292,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 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); + } + + 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 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 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 { - *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 +388,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 +398,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,10 +416,10 @@ 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(), index, len: draft_filters.len(), }, @@ -425,8 +434,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 +461,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 +471,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,10 +491,10 @@ 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(), index, len: draft_search_values.len(), }, @@ -582,7 +594,7 @@ fn section_title(title: &str, count: usize) -> String { format!("{title} ({count})") } -fn render_filter_picker_button(ui: &mut Ui, filter: &SearchFilter) -> egui::Response { +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 +663,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)"); - } -} From c64a2b81069993df62c4eff40f2b3d56012c4021 Mon Sep 17 00:00:00 2001 From: Ammar Abou Zor Date: Sun, 7 Jun 2026 23:17:15 +0200 Subject: [PATCH 2/7] Refactor: Split presets registry & UI panel Those modules where too large to be in one file. Changes included splitting them into multiple files. No code changes included. --- .../src/host/ui/registry/presets/capture.rs | 170 ++++ .../{presets.rs => presets/catalog.rs} | 240 +---- .../app/src/host/ui/registry/presets/mod.rs | 8 + .../app/src/host/ui/registry/presets/model.rs | 120 +++ .../session/ui/bottom_panel/presets/apply.rs | 369 ++++++++ .../session/ui/bottom_panel/presets/edit.rs | 354 +++++++ .../session/ui/bottom_panel/presets/export.rs | 337 +++++++ .../session/ui/bottom_panel/presets/mod.rs | 887 +----------------- .../session/ui/bottom_panel/presets/query.rs | 18 +- .../session/ui/bottom_panel/presets/render.rs | 43 +- 10 files changed, 1447 insertions(+), 1099 deletions(-) create mode 100644 crates/app/src/host/ui/registry/presets/capture.rs rename crates/app/src/host/ui/registry/{presets.rs => presets/catalog.rs} (72%) create mode 100644 crates/app/src/host/ui/registry/presets/mod.rs create mode 100644 crates/app/src/host/ui/registry/presets/model.rs create mode 100644 crates/app/src/session/ui/bottom_panel/presets/apply.rs create mode 100644 crates/app/src/session/ui/bottom_panel/presets/edit.rs create mode 100644 crates/app/src/session/ui/bottom_panel/presets/export.rs 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 72% rename from crates/app/src/host/ui/registry/presets.rs rename to crates/app/src/host/ui/registry/presets/catalog.rs index 1e5ececdf0..430a29bb51 100644 --- a/crates/app/src/host/ui/registry/presets.rs +++ b/crates/app/src/host/ui/registry/presets/catalog.rs @@ -1,18 +1,10 @@ -//! Host registry storage for named preset snapshots. +//! Preset catalog storage and mutation behavior. use std::borrow::Cow; -use egui::Color32; -use processor::search::filter::SearchFilter; use uuid::Uuid; -use crate::{ - host::{ - common::colors::{self, ColorPair}, - ui::registry::filters::FilterRegistry, - }, - session::ui::SessionShared, -}; +use super::{Preset, PresetFilterEntry, PresetSearchValueEntry}; /// Host-level registry for named preset snapshots captured from session filters and charts. #[derive(Debug, Default, Clone)] @@ -22,41 +14,6 @@ pub struct PresetRegistry { definitions_revision: u64, } -/// 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, -} - /// Result of applying a preset edit request. #[derive(Debug, Clone, PartialEq, Eq)] pub enum PresetUpdateOutcome { @@ -78,84 +35,6 @@ pub struct PresetImportSummary { pub renamed_items: usize, } -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, - } - } -} - impl PresetRegistry { /// Returns stored presets in display order. pub fn presets(&self) -> &[Preset] { @@ -250,36 +129,6 @@ impl PresetRegistry { PresetImportSummary { renamed_items } } - /// 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) - } - /// Updates an existing preset with the provided row snapshots. pub fn update_preset( &mut self, @@ -334,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}, }; @@ -837,82 +691,4 @@ mod tests { 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 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/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/session/ui/bottom_panel/presets/apply.rs b/crates/app/src/session/ui/bottom_panel/presets/apply.rs new file mode 100644 index 0000000000..2280e8a71a --- /dev/null +++ b/crates/app/src/session/ui/bottom_panel/presets/apply.rs @@ -0,0 +1,369 @@ +//! 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 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; + }; + + // Materialize preset filters and charts 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 entry in filters { + let PresetFilterEntry { + filter, + enabled: _enabled, + colors: _colors, + } = entry; + 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 entry in search_values { + let PresetSearchValueEntry { + filter, + enabled: _enabled, + color: _color, + } = entry; + let value_id = registry + .filters + .add_search_value(SearchValueDefinition::new(filter)); + 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 + } + + /// 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 processor::search::filter::SearchFilter; + use stypes::{FileFormat, ObserveOrigin}; + use tokio::{runtime::Runtime, sync::mpsc}; + use uuid::Uuid; + + use crate::{ + host::{ + command::HostCommand, + common::parsers::ParserNames, + ui::{ + UiActions, + registry::{ + HostRegistry, + filters::{FilterDefinition, FilterRegistry, SearchValueDefinition}, + presets::Preset, + }, + }, + }, + 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 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_commands(cmd_rx: &mut mpsc::Receiver) -> Vec { + let mut commands = Vec::new(); + while let Ok(command) = cmd_rx.try_recv() { + commands.push(command); + } + commands + } + + #[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_filter = registry + .filters + .get_filter(&filter_id) + .unwrap() + .filter + .clone(); + let preset_search_value = registry + .filters + .get_search_value(&value_id) + .unwrap() + .filter + .clone(); + let preset_id = add_preset_with_default_state( + &mut registry, + "test", + vec![preset_filter.clone(), preset_filter], + vec![preset_search_value.clone(), preset_search_value], + ); + + 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 = add_preset_with_default_state( + &mut registry, + "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()); + } +} 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..113451a5ca --- /dev/null +++ b/crates/app/src/session/ui/bottom_panel/presets/edit.rs @@ -0,0 +1,354 @@ +//! 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 + } + + /// 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 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), + ]); + + 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!(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 29d42160f9..209036b629 100644 --- a/crates/app/src/session/ui/bottom_panel/presets/mod.rs +++ b/crates/app/src/session/ui/bottom_panel/presets/mod.rs @@ -1,14 +1,14 @@ //! Preset tab state and interactions for the session bottom panel. -use egui::{Align, Frame, Layout, Margin, RichText, ScrollArea, Ui, UiBuilder, Widget, vec2}; +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}, }, @@ -19,21 +19,19 @@ use crate::{ ui::{ UiActions, actions::{FileDialogFilter, FileDialogOptions}, - registry::{ - HostRegistry, - filters::{FilterDefinition, FilterRegistry, SearchValueDefinition}, - presets::{Preset, PresetFilterEntry, PresetSearchValueEntry, 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; @@ -41,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 { @@ -69,57 +53,6 @@ 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)] pub enum PresetAction { @@ -312,85 +245,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, @@ -439,26 +293,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 @@ -487,7 +321,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)) } @@ -495,7 +329,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 @@ -514,9 +348,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)), ) } @@ -526,200 +361,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 - .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 - } - - 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 - } - - 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; @@ -740,97 +381,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 filters and charts 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 entry in filters { - let PresetFilterEntry { - filter, - enabled: _enabled, - colors: _colors, - } = entry; - 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 entry in search_values { - let PresetSearchValueEntry { - filter, - enabled: _enabled, - color: _color, - } = entry; - let value_id = registry - .filters - .add_search_value(SearchValueDefinition::new(filter)); - 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: &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 { @@ -838,39 +388,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, @@ -919,15 +463,6 @@ mod tests { id } - 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, @@ -941,14 +476,6 @@ mod tests { .add_preset(preset.name, preset.filters, preset.search_values) } - 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 drain_host_commands(host_cmd_rx: &mut mpsc::Receiver) -> Vec { let mut commands = Vec::new(); while let Ok(command) = host_cmd_rx.try_recv() { @@ -995,90 +522,6 @@ 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 = 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); - } - #[test] fn delete_clears_editor() { let (mut presets, _, _) = new_presets(); @@ -1091,282 +534,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 = 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 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), - ]); - - 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!(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_filter = registry - .filters - .get_filter(&filter_id) - .unwrap() - .filter - .clone(); - let preset_search_value = registry - .filters - .get_search_value(&value_id) - .unwrap() - .filter - .clone(); - let preset_id = add_preset_with_default_state( - &mut registry, - "test", - vec![preset_filter.clone(), preset_filter], - vec![preset_search_value.clone(), preset_search_value], - ); - - 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 = add_preset_with_default_state( - &mut registry, - "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(); 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 bcb385e7bb..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,9 +3,21 @@ 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. 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 1d39f4568d..6578952575 100644 --- a/crates/app/src/session/ui/bottom_panel/presets/render.rs +++ b/crates/app/src/session/ui/bottom_panel/presets/render.rs @@ -6,11 +6,46 @@ use egui::{ }; use uuid::Uuid; -use super::{ - HostRegistry, Preset, PresetAction, PresetBrowseSection, PresetFilterEntry, PresetItemRow, - PresetSearchValueEntry, 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, + 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. From 82dfa9bf5d488e994f8d34046948b1f8276c46f9 Mon Sep 17 00:00:00 2001 From: Ammar Abou Zor Date: Mon, 8 Jun 2026 09:03:52 +0200 Subject: [PATCH 3/7] Apply colors & active state to presets * Apply Colors and active state from presets when applied to a sessions making sure we are not invoking search queries in back end on disabled search entries. --- .../session/ui/bottom_panel/presets/apply.rs | 472 +++++++++++++----- crates/app/src/session/ui/shared/mod.rs | 41 +- .../session/ui/shared/searching/filters.rs | 48 ++ 3 files changed, 432 insertions(+), 129 deletions(-) diff --git a/crates/app/src/session/ui/bottom_panel/presets/apply.rs b/crates/app/src/session/ui/bottom_panel/presets/apply.rs index 2280e8a71a..c80ff22f33 100644 --- a/crates/app/src/session/ui/bottom_panel/presets/apply.rs +++ b/crates/app/src/session/ui/bottom_panel/presets/apply.rs @@ -23,6 +23,8 @@ pub enum PresetApplyOutcome { 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), } @@ -44,44 +46,61 @@ impl PresetsUI { return PresetApplyOutcome::NotFound; }; - // Materialize preset filters and charts 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; + 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: _enabled, - colors: _colors, + enabled, + colors, } = entry; 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; + changed |= + shared.set_filter_entry_state(&mut registry.filters, filter_id, enabled, colors); } - let mut changed_search_values = false; for entry in search_values { let PresetSearchValueEntry { filter, - enabled: _enabled, - color: _color, + enabled, + color, } = entry; let value_id = registry .filters .add_search_value(SearchValueDefinition::new(filter)); - if shared.filters.is_search_value_applied(&value_id) { - continue; - } - - shared.apply_search_value(&mut registry.filters, value_id); - changed_search_values = true; + changed |= shared.set_search_value_entry_state( + &mut registry.filters, + value_id, + enabled, + color, + ); } - let outcome = match (changed_filters, changed_search_values) { + 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), @@ -116,6 +135,7 @@ impl PresetsUI { mod tests { use std::path::PathBuf; + use egui::Color32; use processor::search::filter::SearchFilter; use stypes::{FileFormat, ObserveOrigin}; use tokio::{runtime::Runtime, sync::mpsc}; @@ -124,13 +144,12 @@ mod tests { use crate::{ host::{ command::HostCommand, - common::parsers::ParserNames, + common::{colors::ColorPair, parsers::ParserNames}, ui::{ UiActions, registry::{ HostRegistry, filters::{FilterDefinition, FilterRegistry, SearchValueDefinition}, - presets::Preset, }, }, }, @@ -187,19 +206,6 @@ mod tests { id } - 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_commands(cmd_rx: &mut mpsc::Receiver) -> Vec { let mut commands = Vec::new(); while let Ok(command) = cmd_rx.try_recv() { @@ -208,8 +214,65 @@ mod tests { 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_skips_existing_rows() { + 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(); @@ -217,122 +280,110 @@ mod tests { 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_filter = registry - .filters - .get_filter(&filter_id) - .unwrap() - .filter - .clone(); - let preset_search_value = registry - .filters - .get_search_value(&value_id) - .unwrap() - .filter - .clone(); - let preset_id = add_preset_with_default_state( + 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![preset_filter.clone(), preset_filter], - vec![preset_search_value.clone(), preset_search_value], + 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::NoChanges); + 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, - original_filter_colors - ); + 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, - original_value_color + search_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()); + + let commands = drain_commands(&mut cmd_rx); + assert_eq!(commands.len(), 2); } #[test] - fn apply_preset_appends_and_syncs() { + 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 preset_id = add_preset_with_default_state( + 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![ - 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), - ], + 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, ); - 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::>(), + outcome, + PresetApplyOutcome::Applied(SearchSyncTarget::Filter) + ); + assert_eq!( + applied_filter_values(&shared, ®istry), 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()] + 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(), 2); + assert_eq!(commands.len(), 1); match &commands[0] { SessionCommand::ApplySearchFilter { filters, .. } => { assert_eq!(filters.len(), 2); @@ -341,15 +392,180 @@ mod tests { } 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_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] 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 { From 9b49dcbbee43141175f0614ac2c2277445298a0a Mon Sep 17 00:00:00 2001 From: Ammar Abou Zor Date: Mon, 8 Jun 2026 09:27:48 +0200 Subject: [PATCH 4/7] Presets: Include colors & active in legacy import Importing presets from legacy Chipmunk 3 will include colors and active state in legacy import --- .../app/src/host/service/presets_io/legacy.rs | 301 ++++++++++++++++-- 1 file changed, 283 insertions(+), 18 deletions(-) diff --git a/crates/app/src/host/service/presets_io/legacy.rs b/crates/app/src/host/service/presets_io/legacy.rs index 662fcd6786..e28fa15214 100644 --- a/crates/app/src/host/service/presets_io/legacy.rs +++ b/crates/app/src/host/service/presets_io/legacy.rs @@ -23,6 +23,7 @@ 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}; @@ -30,7 +31,10 @@ use uuid::Uuid; use crate::{ common::validation::{validate_filter, validate_search_value_filter}, - host::ui::registry::presets::Preset, + host::{ + common::colors::{self, ColorPair}, + ui::registry::presets::{Preset, PresetFilterEntry, PresetSearchValueEntry}, + }, }; use super::{ImportWarning, LegacyEntryKind}; @@ -38,12 +42,14 @@ 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>, } @@ -77,7 +83,8 @@ pub fn parse_legacy_from_value( // ] // // Please refer to export/imports tests for legacy export samples. - let envelopes: Vec = serde_json::from_value(Value::Array(items)) + 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(); @@ -138,6 +145,7 @@ pub fn parse_legacy_from_value( 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); @@ -187,11 +195,11 @@ fn parse_legacy_collection(content: &str) -> Result match parse_legacy_filter(payload) { + "filters" => match parse_legacy_filter(payload, filters.len()) { Ok(filter) => filters.push(filter), Err(_) => invalid_filters += 1, }, - "charts" => match parse_legacy_chart(payload) { + "charts" => match parse_legacy_chart(payload, search_values.len()) { Ok(search_value) => search_values.push(search_value), Err(_) => invalid_charts += 1, }, @@ -238,13 +246,19 @@ fn parse_legacy_collection(content: &str) -> Result Result { +/// 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") @@ -270,10 +284,16 @@ fn parse_legacy_filter(payload: &str) -> Result { return Err(JsonError::io(IoError::other("invalid legacy filter"))); } - Ok(filter) + let enabled = parse_legacy_enabled(&value); + let colors = parse_legacy_filter_colors(&value, index); + + let entry = PresetFilterEntry::new(filter, enabled, colors); + + Ok(entry) } -fn parse_legacy_chart(payload: &str) -> Result { +/// 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") @@ -286,27 +306,146 @@ fn parse_legacy_chart(payload: &str) -> Result { return Err(JsonError::io(IoError::other("invalid legacy chart"))); } - Ok(filter) + 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: &crate::host::ui::registry::presets::Preset, - ) -> Vec { + fn search_value_definitions(preset: &Preset) -> Vec { preset .search_values .iter() @@ -324,6 +463,29 @@ mod tests { 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(); @@ -412,6 +574,113 @@ mod tests { 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(); @@ -477,9 +746,7 @@ mod tests { ))); } - fn preset_snapshot( - presets: &[crate::host::ui::registry::presets::Preset], - ) -> Vec<(String, Vec, Vec)> { + fn preset_snapshot(presets: &[Preset]) -> Vec<(String, Vec, Vec)> { presets .iter() .map(|preset| { @@ -492,9 +759,7 @@ mod tests { .collect() } - fn filter_definitions( - preset: &crate::host::ui::registry::presets::Preset, - ) -> Vec { + fn filter_definitions(preset: &Preset) -> Vec { preset .filters .iter() From df67fa5f8d15b87a4ae3dca881ac29f50a858e0c Mon Sep 17 00:00:00 2001 From: Ammar Abou Zor Date: Mon, 8 Jun 2026 11:19:22 +0200 Subject: [PATCH 5/7] Include presets colors & active in UI Display both colors and active/inactive state for filters and search values in presets UI both as read-only with adding a way to activate/deactivate presets in edit mode --- .../session/ui/bottom_panel/presets/edit.rs | 57 ++++++ .../session/ui/bottom_panel/presets/mod.rs | 10 ++ .../session/ui/bottom_panel/presets/render.rs | 164 ++++++++++++++++-- 3 files changed, 212 insertions(+), 19 deletions(-) diff --git a/crates/app/src/session/ui/bottom_panel/presets/edit.rs b/crates/app/src/session/ui/bottom_panel/presets/edit.rs index 113451a5ca..7fd0b9b613 100644 --- a/crates/app/src/session/ui/bottom_panel/presets/edit.rs +++ b/crates/app/src/session/ui/bottom_panel/presets/edit.rs @@ -110,6 +110,38 @@ impl PresetsUI { 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 { @@ -304,6 +336,26 @@ mod tests { 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(); @@ -328,6 +380,8 @@ mod tests { .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); @@ -349,6 +403,9 @@ mod tests { .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/mod.rs b/crates/app/src/session/ui/bottom_panel/presets/mod.rs index 209036b629..a99a47626f 100644 --- a/crates/app/src/session/ui/bottom_panel/presets/mod.rs +++ b/crates/app/src/session/ui/bottom_panel/presets/mod.rs @@ -70,6 +70,10 @@ pub enum PresetAction { 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. @@ -268,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); } 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 6578952575..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,8 +1,8 @@ //! 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; @@ -36,6 +36,8 @@ mod card_metrics { #[derive(Debug, Clone, Copy)] struct PresetItemRow<'a> { label: &'a str, + enabled: bool, + color: Color32, index: usize, len: usize, } @@ -89,10 +91,25 @@ impl PresetsUI { 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); @@ -210,7 +227,7 @@ impl PresetsUI { } for item in items { - self.render_filter_row(ui, &item.filter); + self.render_filter_row(ui, item); } } @@ -225,34 +242,53 @@ impl PresetsUI { } for item in items { - self.render_search_value_row(ui, &item.filter); + 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, filter: &SearchFilter) { + 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| {}, ); @@ -267,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() @@ -276,6 +312,54 @@ 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, @@ -455,10 +539,13 @@ impl PresetsUI { self.render_edit_item_row( PresetItemRow { 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, @@ -530,10 +617,13 @@ impl PresetsUI { self.render_edit_item_row( PresetItemRow { 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, @@ -542,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 @@ -569,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; @@ -584,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; @@ -599,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); + } } } @@ -629,6 +733,28 @@ fn section_title(title: &str, count: usize) -> String { format!("{title} ({count})") } +/// 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()); From 9e304b328ead16975ea8de5c359271dccc375926 Mon Sep 17 00:00:00 2001 From: Ammar Abou Zor Date: Mon, 8 Jun 2026 17:00:51 +0200 Subject: [PATCH 6/7] Notifications Banner: Refactor & Fix stale height Split notification file into two modules separating banner from the normal notification rendering. Notifications banner used to have one ID which will be persisted to over multiple notifications making the notification sharing the max height making new notifications take much more height than needed if we have a large notification previously. --- crates/app/src/host/ui/notification/banner.rs | 138 +++++++++++++++++ .../{notification.rs => notification/mod.rs} | 146 +++--------------- 2 files changed, 161 insertions(+), 123 deletions(-) create mode 100644 crates/app/src/host/ui/notification/banner.rs rename crates/app/src/host/ui/{notification.rs => notification/mod.rs} (64%) 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, From 3ce1f40af9de6fb280eca3bf60e653a573340c5a Mon Sep 17 00:00:00 2001 From: Ammar Abou Zor Date: Mon, 8 Jun 2026 17:57:11 +0200 Subject: [PATCH 7/7] Improve presets import/export notifications Improve message for import/export notifications + Fix clippy warnings --- crates/app/src/host/service/presets_io/v2.rs | 4 +-- crates/app/src/host/ui/state/presets.rs | 36 +++++++++----------- 2 files changed, 19 insertions(+), 21 deletions(-) diff --git a/crates/app/src/host/service/presets_io/v2.rs b/crates/app/src/host/service/presets_io/v2.rs index 8d6cbb2fae..b943850200 100644 --- a/crates/app/src/host/service/presets_io/v2.rs +++ b/crates/app/src/host/service/presets_io/v2.rs @@ -301,12 +301,12 @@ mod tests { assert!(json.contains("\"version\": 2")); assert_eq!(parsed.format, ImportFormat::Version2); assert_eq!(preset_snapshot(&parsed.presets), preset_snapshot(&source)); - assert_eq!(parsed.presets[0].filters[0].enabled, false); + assert!(!parsed.presets[0].filters[0].enabled); assert_eq!( parsed.presets[0].filters[0].colors, source[0].filters[0].colors ); - assert_eq!(parsed.presets[0].search_values[0].enabled, false); + assert!(!parsed.presets[0].search_values[0].enabled); assert_eq!( parsed.presets[0].search_values[0].color, source[0].search_values[0].color diff --git a/crates/app/src/host/ui/state/presets.rs b/crates/app/src/host/ui/state/presets.rs index 8428814653..18a80ebd6c 100644 --- a/crates/app/src/host/ui/state/presets.rs +++ b/crates/app/src/host/ui/state/presets.rs @@ -1,6 +1,7 @@ //! Host UI handlers for preset import and export messages. use std::{ + borrow::Cow, fmt::Write, path::{Path, PathBuf}, }; @@ -50,23 +51,13 @@ fn format_preset_import_report( format: ImportFormat, renamed_count: usize, ) -> String { - let source = match format { - ImportFormat::Version1 | ImportFormat::Version2 => "preset file", - ImportFormat::Legacy => "legacy 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") ) }; @@ -74,7 +65,7 @@ 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") ); } @@ -82,7 +73,8 @@ fn format_preset_import_report( match format { ImportFormat::Version1 => { message.push_str( - " V1 preset files do not store row colors or enabled state, so defaults were applied. Re-export these presets to preserve that state in future imports.", + "\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 => {} @@ -92,13 +84,19 @@ fn format_preset_import_report( } 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 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())) +} + fn pluralize<'a>(count: usize, singular: &'a str, plural: &'a str) -> &'a str { if count == 1 { singular } else { plural } }