diff --git a/application/apps/indexer/gui/application/src/host/ui/mod.rs b/application/apps/indexer/gui/application/src/host/ui/mod.rs index 65d7942609..b1633d76d0 100644 --- a/application/apps/indexer/gui/application/src/host/ui/mod.rs +++ b/application/apps/indexer/gui/application/src/host/ui/mod.rs @@ -192,11 +192,13 @@ impl Host { ); } - let filters = ®istry.filters; - let state = session.capture_opened_recent_state(filters); - let snapshot = recent_registration.into_snapshot(state); + if let Some(recent_registration) = recent_registration { + let filters = ®istry.filters; + let state = session.capture_opened_recent_state(filters); + let snapshot = recent_registration.into_snapshot(state); - self.storage.recent_sessions.register_session(snapshot); + self.storage.recent_sessions.register_session(snapshot); + } } HostMessage::MultiFilesSetup(state) => self .state diff --git a/application/apps/indexer/gui/application/src/host/ui/registry/presets.rs b/application/apps/indexer/gui/application/src/host/ui/registry/presets.rs index 6138e1a4c9..8d616ace6e 100644 --- a/application/apps/indexer/gui/application/src/host/ui/registry/presets.rs +++ b/application/apps/indexer/gui/application/src/host/ui/registry/presets.rs @@ -222,6 +222,7 @@ mod tests { id: session_id, title: "test".to_owned(), parser: ParserNames::Text, + raw_export_supported: false, }; SessionShared::new(session_info, observe_op) diff --git a/application/apps/indexer/gui/application/src/session/command.rs b/application/apps/indexer/gui/application/src/session/command.rs index a0d62d7297..ebd11da01b 100644 --- a/application/apps/indexer/gui/application/src/session/command.rs +++ b/application/apps/indexer/gui/application/src/session/command.rs @@ -7,7 +7,9 @@ use session_core::state::IndexedNavigation; use stypes::GrabbedElement; use uuid::Uuid; -use crate::host::ui::session_setup::state::sources::StreamConfig; +use crate::host::ui::{ + session_setup::state::sources::StreamConfig, storage::RecentSessionStateSnapshot, +}; use crate::session::{error::SessionError, types::attachment}; /// Represents session specific commands to be sent from UI to session service. @@ -98,6 +100,27 @@ pub enum SessionCommand { /// configuration of the current session as basis StartSessionWithSource { source_uuid: String }, + /// Export raw source data for the selected target. + ExportRaw { + operation_id: Uuid, + destination: PathBuf, + target: ExportTarget, + }, + + /// Export rendered text logs for the selected target. + ExportText { + operation_id: Uuid, + destination: PathBuf, + target: ExportTarget, + options: Box, + }, + + /// Export current search results to a generated source and open it in a new session tab. + OpenSearchResultsAsNewTab { + operation_id: Uuid, + restore_state: RecentSessionStateSnapshot, + }, + /// Cancel the running operation with the given id. CancelOperation { id: Uuid }, /// Gracefully terminate the session service. @@ -109,3 +132,25 @@ pub enum AttachSource { Files(Vec), Stream(Box), } + +#[derive(Debug)] +pub enum ExportTarget { + /// All stream rows in the main table. + All, + /// Current indexed lower-table rows. + Indexed, + /// Original stream row positions selected by the UI. + Rows(Vec), +} + +/// Options for rendered text export. +#[derive(Debug, Clone)] +pub enum TextExportOptions { + /// Export full rendered rows without column filtering. + FullRows, + /// Export selected table columns joined by the provided delimiter. + Table { + columns: Vec, + delimiter: String, + }, +} diff --git a/application/apps/indexer/gui/application/src/session/common/mod.rs b/application/apps/indexer/gui/application/src/session/common/mod.rs new file mode 100644 index 0000000000..95d78cc95b --- /dev/null +++ b/application/apps/indexer/gui/application/src/session/common/mod.rs @@ -0,0 +1,3 @@ +//! Shared session logic used by UI and service layers. + +pub mod search_results_tab; diff --git a/application/apps/indexer/gui/application/src/session/common/search_results_tab.rs b/application/apps/indexer/gui/application/src/session/common/search_results_tab.rs new file mode 100644 index 0000000000..31a1dfd729 --- /dev/null +++ b/application/apps/indexer/gui/application/src/session/common/search_results_tab.rs @@ -0,0 +1,198 @@ +//! Shared mode selection for opening exported search results as a new session tab. + +use stypes::{FileFormat, ObserveOrigin}; + +use crate::host::common::parsers::ParserNames; + +/// Export/reopen strategy for search results opened as a generated tab. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SearchResultsTabMode { + /// Export rendered text and reopen it as plain text while keeping the generic tab label. + PreserveText, + /// Export raw DLT bytes and reopen them with the original DLT parser configuration. + PreserveDltBinary, + /// Export rendered text and reopen it as a plain text tab. + Text, +} + +impl SearchResultsTabMode { + /// Resolves the export/reopen strategy for the provided parser and observed sources. + pub fn resolve_from<'a>( + parser: ParserNames, + origins: impl IntoIterator, + ) -> Self { + // Raw exports are only preserved when the exported bytes are a valid source for the + // same parser. + // * PCAP/PCAPNG exports are not valid PCAP containers for both DLT and SomeIP + // * streams cannot be replayed from exported ranges. + // * Plugin sources open as rendered text for now. + + let mut origins = origins.into_iter(); + let Some(first_origin) = origins.next() else { + debug_assert!( + false, + "search-results tab mode requires at least one origin" + ); + + return SearchResultsTabMode::Text; + }; + + let Some(format) = first_file_format(first_origin) else { + return SearchResultsTabMode::Text; + }; + + match (parser, format) { + (ParserNames::Text, FileFormat::Text) => SearchResultsTabMode::PreserveText, + (ParserNames::Dlt, FileFormat::Binary) => SearchResultsTabMode::PreserveDltBinary, + _ => SearchResultsTabMode::Text, + } + } + + /// Returns the context-menu label for this mode. + pub const fn context_menu_label(self) -> &'static str { + match self { + SearchResultsTabMode::PreserveText | SearchResultsTabMode::PreserveDltBinary => { + "Open Search Results as New Tab" + } + SearchResultsTabMode::Text => "Open Search Results as New Text Tab", + } + } +} + +/// Returns the file format that participates in mode selection. +fn first_file_format(origin: &ObserveOrigin) -> Option { + match origin { + ObserveOrigin::File(_, format, _) => Some(*format), + ObserveOrigin::Concat(items) => { + let Some((_, format, _)) = items.first() else { + debug_assert!(false, "concat origin requires at least one source"); + return None; + }; + Some(*format) + } + ObserveOrigin::Stream(_, _) => None, + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use stypes::{FileFormat, ObserveOrigin, TCPTransportConfig, Transport, UDPTransportConfig}; + + use super::*; + + fn file(format: FileFormat) -> ObserveOrigin { + ObserveOrigin::File(String::from("source"), format, PathBuf::from("source.log")) + } + + fn concat(format: FileFormat) -> ObserveOrigin { + ObserveOrigin::Concat(vec![ + (String::from("first"), format, PathBuf::from("first.log")), + ( + String::from("second"), + FileFormat::PcapNG, + PathBuf::from("second.pcapng"), + ), + ]) + } + + fn stream() -> ObserveOrigin { + ObserveOrigin::Stream( + String::from("stream"), + Transport::TCP(TCPTransportConfig { + bind_addr: String::from("127.0.0.1:5555"), + }), + ) + } + + #[test] + fn text_file_preserves_text() { + assert_eq!( + SearchResultsTabMode::resolve_from(ParserNames::Text, &[file(FileFormat::Text)]), + SearchResultsTabMode::PreserveText + ); + } + + #[test] + fn text_concat_preserves_text() { + assert_eq!( + SearchResultsTabMode::resolve_from(ParserNames::Text, &[concat(FileFormat::Text)]), + SearchResultsTabMode::PreserveText + ); + } + + #[test] + fn text_stream_opens_text() { + assert_eq!( + SearchResultsTabMode::resolve_from(ParserNames::Text, &[stream()]), + SearchResultsTabMode::Text + ); + } + + #[test] + fn dlt_binary_file_preserves_binary() { + assert_eq!( + SearchResultsTabMode::resolve_from(ParserNames::Dlt, &[file(FileFormat::Binary)]), + SearchResultsTabMode::PreserveDltBinary + ); + } + + #[test] + fn dlt_binary_concat_preserves_binary() { + assert_eq!( + SearchResultsTabMode::resolve_from(ParserNames::Dlt, &[concat(FileFormat::Binary)]), + SearchResultsTabMode::PreserveDltBinary + ); + } + + #[test] + fn dlt_pcap_opens_text() { + for format in [FileFormat::PcapLegacy, FileFormat::PcapNG] { + assert_eq!( + SearchResultsTabMode::resolve_from(ParserNames::Dlt, &[file(format)]), + SearchResultsTabMode::Text + ); + } + } + + #[test] + fn dlt_stream_opens_text() { + assert_eq!( + SearchResultsTabMode::resolve_from(ParserNames::Dlt, &[stream()]), + SearchResultsTabMode::Text + ); + } + + #[test] + fn someip_sources_open_text() { + let origins = [ + file(FileFormat::Binary), + file(FileFormat::PcapNG), + ObserveOrigin::Stream( + String::from("stream"), + Transport::UDP(UDPTransportConfig { + bind_addr: String::from("127.0.0.1:5555"), + multicast: Vec::new(), + }), + ), + ]; + + for origin in origins { + assert_eq!( + SearchResultsTabMode::resolve_from(ParserNames::SomeIP, &[origin]), + SearchResultsTabMode::Text + ); + } + } + + #[test] + fn plugin_sources_open_text() { + for origin in [file(FileFormat::Text), file(FileFormat::Binary), stream()] { + assert_eq!( + SearchResultsTabMode::resolve_from(ParserNames::Plugins, &[origin]), + SearchResultsTabMode::Text + ); + } + } +} diff --git a/application/apps/indexer/gui/application/src/session/communication.rs b/application/apps/indexer/gui/application/src/session/communication.rs index 918f550bdb..ae8a03d0d5 100644 --- a/application/apps/indexer/gui/application/src/session/communication.rs +++ b/application/apps/indexer/gui/application/src/session/communication.rs @@ -104,6 +104,15 @@ impl ServiceSenders { evaluate_send_res(&self.egui_ctx, res) } + + /// Create [`SharedSenders`] by cloning the channels shared with child sessions. + pub fn get_shared_senders(&self) -> SharedSenders { + SharedSenders::new( + self.host_message_tx.clone(), + self.notification_tx.clone(), + self.egui_ctx.clone(), + ) + } } /// Initialize communication channels for session application. diff --git a/application/apps/indexer/gui/application/src/session/mod.rs b/application/apps/indexer/gui/application/src/session/mod.rs index 8aff9992fe..3e65f9f5f6 100644 --- a/application/apps/indexer/gui/application/src/session/mod.rs +++ b/application/apps/indexer/gui/application/src/session/mod.rs @@ -10,6 +10,7 @@ use crate::{ }; pub mod command; +pub mod common; pub mod communication; pub mod error; pub mod message; @@ -52,19 +53,26 @@ pub struct SessionUiInit { /// Recent-session runtime state needed by one live session. #[derive(Debug)] pub struct RecentSessionRuntimeInit { + /// Optional tracking data for sessions that participate in recent-session storage. + pub tracking: Option, + /// Additional startup observe operations attached before first render. + pub additional_observe_ops: Vec, +} + +/// Recent-session tracking data for one live session. +#[derive(Debug)] +pub struct RecentSessionTrackingInit { /// Stable identity of the recent-session entry owned by this live session. pub source_key: Arc, /// Whether this source shape supports recent bookmark persistence. pub supports_bookmarks: bool, - /// Additional startup observe operations attached before first render. - pub additional_observe_ops: Vec, } /// Host-owned recent-session data associated with a spawned session. #[derive(Debug)] pub struct SpawnedRecentSession { /// Static recent-session metadata owned by the host. - pub registration: RecentSessionRegistration, + pub registration: Option, /// Optional session state to restore on startup. pub restore_state: Option, } diff --git a/application/apps/indexer/gui/application/src/session/service/export.rs b/application/apps/indexer/gui/application/src/session/service/export.rs new file mode 100644 index 0000000000..4c1b436cfc --- /dev/null +++ b/application/apps/indexer/gui/application/src/session/service/export.rs @@ -0,0 +1,445 @@ +//! Session export command handling and generated search-results tab flow. + +use std::{ops::RangeInclusive, path::PathBuf}; + +use itertools::Itertools; +use uuid::Uuid; + +use parsers::COLUMN_SEPARATOR; +use stypes::{ComputationError, FileFormat, ObserveOptions, ObserveOrigin, ParserType}; + +use super::{SessionService, SessionStartup, cleanup_temp_source}; +use crate::{ + host::{ + common::parsers::ParserNames, message::HostMessage, ui::storage::RecentSessionStateSnapshot, + }, + session::{ + InitSessionError, + command::{ExportTarget, TextExportOptions}, + common::search_results_tab::SearchResultsTabMode, + error::SessionError, + ui::definitions::schema, + }, +}; + +/// In-flight generated search-results source waiting for export completion. +#[derive(Debug)] +pub struct SearchResultsTabOperation { + /// Backend export operation id that must complete before opening the generated source. + pub operation_id: Uuid, + /// Temp file that receives exported search results and becomes the new session source. + pub destination: PathBuf, + /// Recent-session state to restore in the generated tab. + pub restore_state: RecentSessionStateSnapshot, + /// Export and reopen strategy resolved when the operation started. + pub mode: SearchResultsTabMode, +} + +impl SessionService { + /// Starts a raw export for the requested target and reports skipped phases. + pub async fn handle_raw_export( + &self, + operation_id: Uuid, + destination: PathBuf, + target: ExportTarget, + ) -> Result<(), SessionError> { + let ranges = self.export_ranges(target).await?; + + if ranges.is_empty() { + self.send_operation_skipped(operation_id).await; + return Ok(()); + } + + if let Err(error) = self.session.export_raw(operation_id, destination, ranges) { + return Err(error.into()); + } + + Ok(()) + } + + /// Starts a rendered text export for the requested target and reports skipped phases. + pub async fn handle_text_export( + &self, + operation_id: Uuid, + destination: PathBuf, + target: ExportTarget, + options: TextExportOptions, + ) -> Result<(), SessionError> { + let ranges = self.export_ranges(target).await?; + + if ranges.is_empty() { + self.send_operation_skipped(operation_id).await; + return Ok(()); + } + + let (columns, splitter, delimiter) = match options { + TextExportOptions::FullRows => (Vec::new(), None, None), + TextExportOptions::Table { columns, delimiter } => { + (columns, Some(COLUMN_SEPARATOR.to_owned()), Some(delimiter)) + } + }; + + if let Err(error) = self.session.export( + operation_id, + destination, + ranges, + columns, + splitter, + delimiter, + ) { + return Err(error.into()); + } + + Ok(()) + } + + /// Exports current indexed search results to a generated source for a new session tab. + pub async fn open_search_results_tab( + &mut self, + operation_id: Uuid, + restore_state: RecentSessionStateSnapshot, + ) -> Result<(), SessionError> { + // Only one generated-results export can be handed off at a time because the + // callback path stores a single pending operation to open after export completes. + if self.tracker.search_results_tab.is_some() { + self.send_operation_skipped(operation_id).await; + return Ok(()); + } + + // The new tab should contain exactly the rows currently available in the indexed + // search-results map. Empty searches are reported as skipped instead of creating a file. + let ranges = self.export_ranges(ExportTarget::Indexed).await?; + + if ranges.is_empty() { + self.send_operation_skipped(operation_id).await; + return Ok(()); + } + + // Recompute mode from executed options so UI label and service behavior use the + // same rules without trusting a UI-provided mode. + let executed = self + .session + .state + .get_executed_holder() + .await + .map_err(SessionError::NativeError)? + .executed; + let mode = resolve_mode_from_executed(&executed); + let destination = new_search_results_path(operation_id, mode)?; + + // Track ownership until the backend confirms export completion. On success the + // generated path is transferred to the newly spawned session service. + self.tracker.search_results_tab = Some(SearchResultsTabOperation { + operation_id, + destination: destination.clone(), + restore_state, + mode, + }); + + // Preserve raw bytes only when the export is a valid source for the target parser. + // Other modes export rendered text, with DLT/SomeIP fallback formatted as table text. + let result = + match mode { + SearchResultsTabMode::PreserveDltBinary => { + self.session.export_raw(operation_id, destination, ranges) + } + SearchResultsTabMode::PreserveText => { + self.session + .export(operation_id, destination, ranges, Vec::new(), None, None) + } + SearchResultsTabMode::Text => { + let parser = executed + .first() + .map(|options| ParserNames::from(&options.parser)); + match parser { + Some(parser @ (ParserNames::Dlt | ParserNames::SomeIP)) => { + // In case of falling back to export DLT/SomeIP as text then use best effort + // separator to show them similar to columns as possible. + const FALLBACK_TEXT_DELIMITER: &str = " | "; + let schema = schema::from_parser(parser); + let columns = (0..schema.columns().len()).collect(); + + self.session.export( + operation_id, + destination, + ranges, + columns, + Some(COLUMN_SEPARATOR.to_owned()), + Some(FALLBACK_TEXT_DELIMITER.to_owned()), + ) + } + Some(ParserNames::Text | ParserNames::Plugins) | None => self + .session + .export(operation_id, destination, ranges, Vec::new(), None, None), + } + } + }; + + // Export start failures happen before OperationError callbacks, so cleanup the + // generated path and clear tracking here. + if let Err(error) = result { + if let Some(operation) = self.tracker.search_results_tab.take() { + cleanup_temp_source(&operation.destination); + } + return Err(error.into()); + } + + Ok(()) + } + + /// Opens the generated search-results source when its tracked export operation completes. + pub async fn finish_results_tab(&mut self, operation_id: Uuid) -> Result<(), SessionError> { + let is_tracked = self + .tracker + .search_results_tab + .as_ref() + .is_some_and(|operation| operation.operation_id == operation_id); + if !is_tracked { + return Ok(()); + } + + let operation = self + .tracker + .search_results_tab + .take() + .expect("tracked operation must exist"); + + self.create_results_tab(operation).await + } + + /// Resolves an export target into compact inclusive stream-row ranges. + async fn export_ranges( + &self, + target: ExportTarget, + ) -> Result>, SessionError> { + match target { + ExportTarget::All => { + let len = self.session.get_stream_len().await?; + if len == 0 { + Ok(Vec::new()) + } else { + Ok(vec![0..=(len as u64 - 1)]) + } + } + ExportTarget::Indexed => { + let ranges = self + .session + .get_indexed_ranges() + .await? + .0 + .into_iter() + .map(|range| range.start..=range.end) + .collect_vec(); + Ok(ranges) + } + ExportTarget::Rows(rows) => Ok(rows_to_ranges(rows)), + } + } + + /// Creates the new session from a generated search-results temp source. + async fn create_results_tab( + &mut self, + operation: SearchResultsTabOperation, + ) -> Result<(), SessionError> { + let options = match self.search_results_observe_options(&operation).await { + Ok(options) => options, + Err(error) => { + cleanup_temp_source(&operation.destination); + return Err(error); + } + }; + + let child_session_id = Uuid::new_v4(); + let (child_session, child_callback_rx) = + match session_core::session::Session::new(child_session_id).await { + Ok(session_parts) => session_parts, + Err(error) => { + cleanup_temp_source(&operation.destination); + return Err(init_session_error_to_session_error(error.into())); + } + }; + let shared_senders = self.senders.get_shared_senders(); + let additional_sources = Vec::new(); + let restore_state = operation.restore_state; + let temp_source = operation.destination.clone(); + let startup = SessionStartup::new( + shared_senders, + child_session, + child_callback_rx, + options, + additional_sources, + ) + .with_restore_state(Some(restore_state)) + .with_temp_source(temp_source) + .with_recent_session(false); + + let session = Self::start(startup).map_err(init_session_error_to_session_error); + + let session = match session { + Ok(session) => session, + Err(error) => { + cleanup_temp_source(&operation.destination); + return Err(error); + } + }; + + self.senders + .send_host_message(HostMessage::SessionCreated { + session: Box::new(session), + session_setup_id: None, + }) + .await; + + Ok(()) + } + + /// Builds observe options for reopening a generated search-results source. + async fn search_results_observe_options( + &self, + operation: &SearchResultsTabOperation, + ) -> Result { + let parser = match operation.mode { + SearchResultsTabMode::PreserveDltBinary => { + let executed = self + .session + .state + .get_executed_holder() + .await + .map_err(SessionError::NativeError)? + .executed; + match executed.first().map(|options| &options.parser) { + Some(ParserType::Dlt(settings)) => ParserType::Dlt(settings.clone()), + _ => { + debug_assert!( + false, + "preserved DLT search-results tab requires DLT parser" + ); + ParserType::Text(()) + } + } + } + SearchResultsTabMode::PreserveText | SearchResultsTabMode::Text => ParserType::Text(()), + }; + + let file_format = match operation.mode { + SearchResultsTabMode::PreserveDltBinary => FileFormat::Binary, + SearchResultsTabMode::PreserveText | SearchResultsTabMode::Text => FileFormat::Text, + }; + + Ok(ObserveOptions { + origin: ObserveOrigin::File( + Uuid::new_v4().to_string(), + file_format, + operation.destination.clone(), + ), + parser, + }) + } +} + +/// Resolves the search-results tab mode from executed observe options. +fn resolve_mode_from_executed(executed: &[ObserveOptions]) -> SearchResultsTabMode { + let Some(first) = executed.first() else { + debug_assert!( + false, + "search-results tab mode requires executed observe options" + ); + return SearchResultsTabMode::Text; + }; + + let parser = ParserNames::from(&first.parser); + let origins = executed.iter().map(|options| &options.origin); + + SearchResultsTabMode::resolve_from(parser, origins) +} + +/// Builds the temp destination path for a generated search-results source. +fn new_search_results_path( + operation_id: Uuid, + mode: SearchResultsTabMode, +) -> Result { + let extension = match mode { + SearchResultsTabMode::PreserveDltBinary => "dlt", + SearchResultsTabMode::PreserveText | SearchResultsTabMode::Text => "txt", + }; + + let path = session_core::paths::get_streams_dir() + .map_err(SessionError::NativeError)? + .join(format!("search-results-{operation_id}.{extension}")); + + Ok(path) +} + +/// Converts startup failures into the session error channel used by live sessions. +fn init_session_error_to_session_error(error: InitSessionError) -> SessionError { + match error { + InitSessionError::IO(error) => ComputationError::IoOperation(error.to_string()).into(), + InitSessionError::Computation(error) => error.into(), + InitSessionError::Other(error) => ComputationError::Process(error).into(), + } +} + +/// Converts selected stream row positions into compact inclusive ranges for raw export. +/// +/// The UI snapshots selection from a hash set and does not own export range semantics, so +/// the service normalizes row order, removes duplicates, and compacts adjacent rows here. +fn rows_to_ranges(mut rows: Vec) -> Vec> { + rows.sort_unstable(); + rows.dedup(); + + let mut ranges = Vec::new(); + let mut rows = rows.into_iter(); + let Some(mut start) = rows.next() else { + return ranges; + }; + let mut end = start; + + for row in rows { + if end.checked_add(1) == Some(row) { + end = row; + } else { + ranges.push(start..=end); + start = row; + end = row; + } + } + + ranges.push(start..=end); + ranges +} + +#[cfg(test)] +mod tests { + use std::ops::RangeInclusive; + + use super::rows_to_ranges; + + fn ranges(rows: Vec) -> Vec> { + rows_to_ranges(rows) + } + + #[test] + fn empty_rows_make_no_ranges() { + assert!(ranges(Vec::new()).is_empty()); + } + + #[test] + fn single_row_makes_single_range() { + assert_eq!(ranges(vec![7]), vec![7..=7]); + } + + #[test] + fn unsorted_rows_make_ordered_ranges() { + assert_eq!(ranges(vec![5, 3, 4, 10]), vec![3..=5, 10..=10]); + } + + #[test] + fn duplicate_rows_are_deduped() { + assert_eq!(ranges(vec![2, 2, 3, 5, 5]), vec![2..=3, 5..=5]); + } + + #[test] + fn gaps_make_multiple_ranges() { + assert_eq!(ranges(vec![1, 2, 4, 7, 8]), vec![1..=2, 4..=4, 7..=8]); + } +} diff --git a/application/apps/indexer/gui/application/src/session/service/mod.rs b/application/apps/indexer/gui/application/src/session/service/mod.rs index 34c520dd22..da43c986bd 100644 --- a/application/apps/indexer/gui/application/src/session/service/mod.rs +++ b/application/apps/indexer/gui/application/src/session/service/mod.rs @@ -1,4 +1,9 @@ -use std::{ops::ControlFlow, path::Path, sync::Arc}; +use std::{ + fs, + ops::ControlFlow, + path::{Path, PathBuf}, + sync::Arc, +}; use image::ImageError; use itertools::Itertools; @@ -9,6 +14,9 @@ use processor::grabber::LineRange; use session_core::session::Session; use stypes::{CallbackEvent, ComputationError, ObserveOptions, ObserveOrigin, Transport}; +mod export; +mod tracker; + use super::{command::SessionCommand, communication::ServiceHandle, error::SessionError}; use crate::{ common::time::unix_timestamp_now, @@ -26,8 +34,8 @@ use crate::{ }, }, session::{ - InitSessionError, RecentSessionRuntimeInit, SessionUiInit, SpawnedRecentSession, - SpawnedSession, + InitSessionError, RecentSessionRuntimeInit, RecentSessionTrackingInit, SessionUiInit, + SpawnedRecentSession, SpawnedSession, command::AttachSource, communication::{self, ServiceSenders, SharedSenders}, message::{BookmarkUpdate, SessionMessage}, @@ -39,12 +47,50 @@ use crate::{ }, }; +use self::tracker::OperationTracker; + +/// Async backend coordinator for one live session tab. #[derive(Debug)] pub struct SessionService { + /// Commands received from the session UI. cmd_rx: mpsc::Receiver, + /// Channels used to publish session, host, and notification messages. senders: ServiceSenders, + /// Core backend session API. session: Session, + /// Backend callback stream for this session. callback_rx: mpsc::UnboundedReceiver, + /// Follow-up state for operations completed through backend callbacks. + tracker: OperationTracker, + /// Temp sources owned and cleaned up when this service closes. + owned_temp_sources: Vec, +} + +/// Inputs required to start one running session service. +#[derive(Debug)] +struct SessionStartup { + /// Channels shared with the host and UI layers. + shared_senders: SharedSenders, + /// Backend session instance to drive. + session: Session, + /// Backend callback stream paired with `session`. + callback_rx: mpsc::UnboundedReceiver, + /// Initial observe options for the primary source. + options: ObserveOptions, + /// Extra sources observed during startup with the same parser. + additional_sources: Vec, + /// Optional UI state restored after session creation. + restore_state: Option, + /// Temp sources owned and cleaned up by this service. + owned_temp_sources: Vec, + /// Whether this session participates in recent-session storage. + recent_session_policy: RecentSessionPolicy, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RecentSessionPolicy { + Register, + Skip, } impl SessionService { @@ -61,11 +107,34 @@ impl SessionService { restore_state: Option, ) -> Result { let session_id = Uuid::new_v4(); + let (session, callback_rx) = Session::new(session_id).await?; - let (ui_handle, service_handle) = communication::init(shared_senders); + let startup = SessionStartup::new( + shared_senders, + session, + callback_rx, + options, + additional_sources, + ) + .with_restore_state(restore_state); - // Initial session with the primary source. - let (session, callback_rx) = session_core::session::Session::new(session_id).await?; + Self::start(startup) + } + + fn start(startup: SessionStartup) -> Result { + let SessionStartup { + shared_senders, + session, + callback_rx, + options, + additional_sources, + restore_state, + owned_temp_sources, + recent_session_policy, + } = startup; + + let session_id = session.get_uuid(); + let (ui_handle, service_handle) = communication::init(shared_senders); let session_info = SessionInfo::from_observe_options(session_id, &options); let mut recent_sources = RecentSessionSource::from_observe_origin(options.origin.clone()); @@ -92,11 +161,22 @@ impl SessionService { startup_observe_ops.push(observe_op); } - // Build register to track session changes in recent session storage. - let recent_registration = - RecentSessionRegistration::new(unix_timestamp_now(), recent_sources, parser); - let supports_bookmarks = recent_registration.supports_bookmarks(); - let recent_source_key = Arc::clone(&recent_registration.source_key); + // Build registration to track normal sessions in recent-session storage. + let recent_registration = match recent_session_policy { + RecentSessionPolicy::Register => Some(RecentSessionRegistration::new( + unix_timestamp_now(), + recent_sources, + parser, + )), + RecentSessionPolicy::Skip => None, + }; + let recent_tracking = + recent_registration + .as_ref() + .map(|registration| RecentSessionTrackingInit { + source_key: Arc::clone(®istration.source_key), + supports_bookmarks: registration.supports_bookmarks(), + }); let ServiceHandle { cmd_rx, senders } = service_handle; @@ -105,6 +185,8 @@ impl SessionService { senders, session, callback_rx, + tracker: OperationTracker::default(), + owned_temp_sources, }; tokio::spawn(async move { @@ -114,8 +196,7 @@ impl SessionService { let ui_init = SessionUiInit { session_info, recent_runtime: RecentSessionRuntimeInit { - source_key: recent_source_key, - supports_bookmarks, + tracking: recent_tracking, additional_observe_ops: startup_observe_ops, }, communication: ui_handle, @@ -439,6 +520,45 @@ impl SessionService { return Err(ComputationError::SessionCreatingFail.into()); } + SessionCommand::ExportRaw { + operation_id, + destination, + target, + } => { + if let Err(error) = self + .handle_raw_export(operation_id, destination, target) + .await + { + self.send_operation_failed(operation_id).await; + return Err(error); + } + } + SessionCommand::ExportText { + operation_id, + destination, + target, + options, + } => { + if let Err(error) = self + .handle_text_export(operation_id, destination, target, *options) + .await + { + self.send_operation_failed(operation_id).await; + return Err(error); + } + } + SessionCommand::OpenSearchResultsAsNewTab { + operation_id, + restore_state, + } => { + if let Err(error) = self + .open_search_results_tab(operation_id, restore_state) + .await + { + self.send_operation_failed(operation_id).await; + return Err(error); + } + } SessionCommand::CancelOperation { id } => { self.session.abort(Uuid::new_v4(), id)?; } @@ -457,6 +577,24 @@ impl SessionService { Ok(ControlFlow::Continue(())) } + async fn send_operation_failed(&self, operation_id: Uuid) { + self.senders + .send_session_msg(SessionMessage::OperationUpdated { + operation_id, + phase: OperationPhase::Failed, + }) + .await; + } + + async fn send_operation_skipped(&self, operation_id: Uuid) { + self.senders + .send_session_msg(SessionMessage::OperationUpdated { + operation_id, + phase: OperationPhase::Skipped, + }) + .await; + } + async fn preview_attachment(&self, request: PreviewRequest) { let attachment_id = request.attachment_id; let target = request.target; @@ -581,11 +719,21 @@ impl SessionService { .await; } CallbackEvent::OperationError { uuid, error } => { + if self + .tracker + .search_results_tab + .as_ref() + .is_some_and(|operation| operation.operation_id == uuid) + && let Some(operation) = self.tracker.search_results_tab.take() + { + cleanup_temp_source(&operation.destination); + } + // Stop running operation on errors besides sending the notification. self.senders .send_session_msg(SessionMessage::OperationUpdated { operation_id: uuid, - phase: OperationPhase::Done, + phase: OperationPhase::Failed, }) .await; self.send_error(SessionError::NativeError(error)).await; @@ -607,10 +755,20 @@ impl SessionService { .await; } CallbackEvent::OperationDone(done) => { + if let Err(error) = self.finish_results_tab(done.uuid).await { + self.senders + .send_session_msg(SessionMessage::OperationUpdated { + operation_id: done.uuid, + phase: OperationPhase::Failed, + }) + .await; + return Err(error); + } + self.senders .send_session_msg(SessionMessage::OperationUpdated { operation_id: done.uuid, - phase: OperationPhase::Done, + phase: OperationPhase::Success, }) .await; } @@ -634,6 +792,75 @@ impl SessionService { } } +fn cleanup_temp_source(path: &Path) { + match fs::remove_file(path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => log::warn!( + "Failed to remove temp search-results source {}: {error}", + path.display() + ), + } +} + +impl Drop for SessionService { + fn drop(&mut self) { + for path in self.owned_temp_sources.drain(..) { + cleanup_temp_source(&path); + } + + if let Some(operation) = self.tracker.search_results_tab.take() { + cleanup_temp_source(&operation.destination); + } + } +} + +impl SessionStartup { + /// Creates startup inputs with no restore state or owned temp sources. + fn new( + shared_senders: SharedSenders, + session: Session, + callback_rx: mpsc::UnboundedReceiver, + options: ObserveOptions, + additional_sources: Vec, + ) -> Self { + Self { + shared_senders, + session, + callback_rx, + options, + additional_sources, + restore_state: None, + owned_temp_sources: Vec::new(), + recent_session_policy: RecentSessionPolicy::Register, + } + } + + /// Adds optional restore state to apply after UI creation. + fn with_restore_state(mut self, restore_state: Option) -> Self { + self.restore_state = restore_state; + self + } + + /// Transfers ownership of one generated temp source to the service. + fn with_temp_source(mut self, path: PathBuf) -> Self { + self.owned_temp_sources.push(path); + self + } + + /// Sets whether the session participates in recent-session storage. + /// + /// Recent-session registration is enabled by default. + fn with_recent_session(mut self, enabled: bool) -> Self { + self.recent_session_policy = if enabled { + RecentSessionPolicy::Register + } else { + RecentSessionPolicy::Skip + }; + self + } +} + fn decode_image(path: &Path) -> Result { let image = image::ImageReader::open(path)?.decode()?; let size = [image.width() as _, image.height() as _]; diff --git a/application/apps/indexer/gui/application/src/session/service/operation_track.rs b/application/apps/indexer/gui/application/src/session/service/operation_track.rs deleted file mode 100644 index 06b0e50189..0000000000 --- a/application/apps/indexer/gui/application/src/session/service/operation_track.rs +++ /dev/null @@ -1,22 +0,0 @@ -use uuid::Uuid; - -/// This will be used for long running operations which can be cancelled. -#[derive(Debug, Clone, Default)] -pub struct OperationTracker { - pub filter_op: Option, -} - -impl OperationTracker { - /// Get all still running operations. - pub fn get_all(&self) -> Vec { - let Self { filter_op } = self; - - let mut ops = Vec::new(); - - if let Some(filter_op) = filter_op { - ops.push(*filter_op); - } - - ops - } -} diff --git a/application/apps/indexer/gui/application/src/session/service/tracker.rs b/application/apps/indexer/gui/application/src/session/service/tracker.rs new file mode 100644 index 0000000000..19dd382202 --- /dev/null +++ b/application/apps/indexer/gui/application/src/session/service/tracker.rs @@ -0,0 +1,10 @@ +//! Service-owned state for operations that need follow-up after backend callbacks. + +use super::export::SearchResultsTabOperation; + +/// Tracks operations that need service-side work after backend completion callbacks. +#[derive(Debug, Default)] +pub struct OperationTracker { + /// Pending generated search-results tab export, if one is in progress. + pub search_results_tab: Option, +} diff --git a/application/apps/indexer/gui/application/src/session/types/mod.rs b/application/apps/indexer/gui/application/src/session/types/mod.rs index 1399efe299..196712ae3f 100644 --- a/application/apps/indexer/gui/application/src/session/types/mod.rs +++ b/application/apps/indexer/gui/application/src/session/types/mod.rs @@ -9,11 +9,16 @@ use stypes::ObserveOrigin; /// Represents a running observe operations with its info. #[derive(Debug, Clone)] pub struct ObserveOperation { + /// Backend operation identifier. pub id: Uuid, + /// Current lifecycle phase. phase: OperationPhase, + /// Source being observed. pub origin: ObserveOrigin, + /// Time when tracking started. started: Instant, - duration: Option, + /// Elapsed time once the operation reaches a terminal phase. + run_duration: Option, } impl ObserveOperation { @@ -23,15 +28,15 @@ impl ObserveOperation { phase: OperationPhase::Initializing, origin, started: Instant::now(), - duration: None, + run_duration: None, } } pub fn set_phase(&mut self, phase: OperationPhase) { match phase { OperationPhase::Initializing | OperationPhase::Processing => {} - OperationPhase::Done => { - self.duration = Some(self.started.elapsed()); + OperationPhase::Success | OperationPhase::Failed | OperationPhase::Skipped => { + self.run_duration = Some(self.started.elapsed()); } } @@ -43,8 +48,8 @@ impl ObserveOperation { self.phase } - pub fn total_run_duration(&self) -> Option { - self.duration + pub fn run_duration(&self) -> Option { + self.run_duration } pub fn processing(&self) -> bool { @@ -52,7 +57,7 @@ impl ObserveOperation { } pub fn done(&self) -> bool { - self.phase == OperationPhase::Done + !self.phase.is_running() } pub fn initializing(&self) -> bool { @@ -61,21 +66,37 @@ impl ObserveOperation { } /// Represents a running operation phase. -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OperationPhase { /// Operation is started and waiting to start processing it's data. Initializing, /// Operation is processing data. Processing, - /// Operation is done. - Done, + /// Operation completed successfully. + Success, + /// Operation failed. + Failed, + /// Operation was skipped before processing work. + Skipped, } impl OperationPhase { + /// Returns whether the operation is currently running and not terminal yet. pub fn is_running(self) -> bool { match self { OperationPhase::Initializing | OperationPhase::Processing => true, - OperationPhase::Done => false, + OperationPhase::Success | OperationPhase::Failed | OperationPhase::Skipped => false, + } + } + + /// Returns whether the operation is in initializing phase. + pub fn is_initializing(self) -> bool { + match self { + OperationPhase::Initializing => true, + OperationPhase::Processing + | OperationPhase::Success + | OperationPhase::Failed + | OperationPhase::Skipped => false, } } } diff --git a/application/apps/indexer/gui/application/src/session/ui/bottom_panel/chart/data.rs b/application/apps/indexer/gui/application/src/session/ui/bottom_panel/chart/data.rs index 97c1f3a394..be7fbe20c9 100644 --- a/application/apps/indexer/gui/application/src/session/ui/bottom_panel/chart/data.rs +++ b/application/apps/indexer/gui/application/src/session/ui/bottom_panel/chart/data.rs @@ -197,6 +197,7 @@ mod tests { id: session_id, title: "test".to_owned(), parser: ParserNames::Text, + raw_export_supported: false, }; SessionShared::new(session_info, observe_op) diff --git a/application/apps/indexer/gui/application/src/session/ui/bottom_panel/chart/mod.rs b/application/apps/indexer/gui/application/src/session/ui/bottom_panel/chart/mod.rs index 384534170d..a1b7e2aaf9 100644 --- a/application/apps/indexer/gui/application/src/session/ui/bottom_panel/chart/mod.rs +++ b/application/apps/indexer/gui/application/src/session/ui/bottom_panel/chart/mod.rs @@ -9,7 +9,6 @@ use crate::{ host::ui::{UiActions, registry::filters::FilterRegistry}, session::{ command::SessionCommand, - types::OperationPhase, ui::shared::{SearchTableSync, SessionShared}, }, }; @@ -137,16 +136,16 @@ impl ChartUI { let search_values_phase = shared.search_values.operation_phase(); match (filter_phase, search_values_phase) { - // Any have values - (Some(OperationPhase::Processing | OperationPhase::Done), _) - | (_, Some(OperationPhase::Processing | OperationPhase::Done)) => { - ChartRenderState::Chart + (None, None) => ChartRenderState::Placeholder, + (Some(filter_phase), Some(search_values_phase)) + if filter_phase.is_initializing() && search_values_phase.is_initializing() => + { + ChartRenderState::Spinner } - // Else if any is still Initializing - (Some(OperationPhase::Initializing), _) | (_, Some(OperationPhase::Initializing)) => { + (Some(phase), None) | (None, Some(phase)) if phase.is_initializing() => { ChartRenderState::Spinner } - (None, None) => ChartRenderState::Placeholder, + _ => ChartRenderState::Chart, } } @@ -547,6 +546,7 @@ mod tests { id: session_id, title: "test".to_owned(), parser: ParserNames::Text, + raw_export_supported: false, }; let mut shared = SessionShared::new(session_info, observe_op); diff --git a/application/apps/indexer/gui/application/src/session/ui/bottom_panel/library/mod.rs b/application/apps/indexer/gui/application/src/session/ui/bottom_panel/library/mod.rs index 39d733101c..2e052d69b0 100644 --- a/application/apps/indexer/gui/application/src/session/ui/bottom_panel/library/mod.rs +++ b/application/apps/indexer/gui/application/src/session/ui/bottom_panel/library/mod.rs @@ -662,6 +662,7 @@ mod tests { id: session_id, title: "test".to_owned(), parser: ParserNames::Text, + raw_export_supported: false, }; SessionShared::new(session_info, observe_op) diff --git a/application/apps/indexer/gui/application/src/session/ui/bottom_panel/presets/mod.rs b/application/apps/indexer/gui/application/src/session/ui/bottom_panel/presets/mod.rs index 660fcd3c8e..048eaaa5c0 100644 --- a/application/apps/indexer/gui/application/src/session/ui/bottom_panel/presets/mod.rs +++ b/application/apps/indexer/gui/application/src/session/ui/bottom_panel/presets/mod.rs @@ -856,6 +856,7 @@ mod tests { id: session_id, title: "test".to_owned(), parser: ParserNames::Text, + raw_export_supported: false, }; SessionShared::new(session_info, observe_op) diff --git a/application/apps/indexer/gui/application/src/session/ui/bottom_panel/search/mod.rs b/application/apps/indexer/gui/application/src/session/ui/bottom_panel/search/mod.rs index 660c906493..18f7e4cd58 100644 --- a/application/apps/indexer/gui/application/src/session/ui/bottom_panel/search/mod.rs +++ b/application/apps/indexer/gui/application/src/session/ui/bottom_panel/search/mod.rs @@ -56,7 +56,7 @@ impl SearchUI { // they will be used as identifiers for table state to avoid ID clashes between // tables from different tabs (different sessions). ui.push_id(shared.get_id(), |ui| { - self.table.render_content(shared, actions, ui); + self.table.render_content(shared, actions, registry, ui); }); } } diff --git a/application/apps/indexer/gui/application/src/session/ui/bottom_panel/search/search_bar.rs b/application/apps/indexer/gui/application/src/session/ui/bottom_panel/search/search_bar.rs index ee2f736ed3..74e7eb26ee 100644 --- a/application/apps/indexer/gui/application/src/session/ui/bottom_panel/search/search_bar.rs +++ b/application/apps/indexer/gui/application/src/session/ui/bottom_panel/search/search_bar.rs @@ -399,6 +399,7 @@ mod tests { id: Uuid::new_v4(), title: "test".to_owned(), parser: ParserNames::Text, + raw_export_supported: false, }; let observe_op = ObserveOperation::new( Uuid::new_v4(), diff --git a/application/apps/indexer/gui/application/src/session/ui/bottom_panel/search/search_table.rs b/application/apps/indexer/gui/application/src/session/ui/bottom_panel/search/search_table.rs index 04740bbf1e..da5c0a9239 100644 --- a/application/apps/indexer/gui/application/src/session/ui/bottom_panel/search/search_table.rs +++ b/application/apps/indexer/gui/application/src/session/ui/bottom_panel/search/search_table.rs @@ -6,13 +6,18 @@ use std::{ use egui::{Sense, Ui}; use egui_table::{CellInfo, PrefetchInfo, TableDelegate}; -use stypes::{GrabbedElement, NearestPosition}; use tokio::sync::mpsc::Sender; +use stypes::{GrabbedElement, NearestPosition}; + use crate::{ - host::ui::UiActions, + host::{ + common::parsers::ParserNames, + ui::{UiActions, registry::filters::FilterRegistry}, + }, session::{ - command::SessionCommand, + command::{ExportTarget, SessionCommand}, + common::search_results_tab::SearchResultsTabMode, error::SessionError, types::attachment::{PreviewKind, PreviewRequest, PreviewTarget, kind_for_mime}, ui::{ @@ -31,12 +36,14 @@ use crate::{ }, definitions::{LogTableItem, schema::LogSchema}, logs_table::LogAttachmentInfo, - shared::{SearchTableSync, SessionShared}, + recent::capture_state_snapshot, + shared::{SearchTableSync, SessionShared, export}, }, }, }; const TABLE_ID_SALT: &str = "search_table"; +const EXPORT_DIALOG_ID: &str = "search_table_export"; #[derive(Debug)] pub struct SearchTable { @@ -57,7 +64,7 @@ impl SearchTable { cmd_tx, last_visible_rows: None, pending_scroll: None, - indexed_logs: LogsMapped::new(schema), + indexed_logs: LogsMapped::new(Rc::clone(&schema)), pending_logs_rx: None, scroll_nearest_pos: None, } @@ -71,6 +78,7 @@ impl SearchTable { &mut self, shared: &mut SessionShared, actions: &mut UiActions, + registry: &FilterRegistry, ui: &mut Ui, ) { // Disable fade effects on tables to avoid highlighting clashing. @@ -110,8 +118,16 @@ impl SearchTable { table = table.scroll_to_rows(rows, None); } - let mut delegate = LogsDelegate::new(self, shared, actions); + let mut delegate = LogsDelegate::new(self, shared, actions, registry); let response = table.show(ui, &mut delegate); + response.context_menu(|ui| { + delegate.table.render_context_menu( + delegate.shared, + delegate.actions, + delegate.registry, + ui, + ); + }); if delegate.request_repaint { ui.request_repaint(); @@ -138,6 +154,194 @@ impl SearchTable { *pending_logs_rx = None; } + fn render_context_menu( + &mut self, + shared: &mut SessionShared, + actions: &mut UiActions, + registry: &FilterRegistry, + ui: &mut Ui, + ) { + common::log_table::table::render_unselect_action(shared, ui); + + let can_start_export = shared.exports.can_start(); + let selected_count = shared.logs.selected_count(); + let indexed_count = shared.search.indexed_result_count(); + + match shared.get_info().parser { + ParserNames::Text | ParserNames::Plugins => { + let selected_label = if selected_count == 0 { + String::from("Export Selected") + } else { + format!("Export {selected_count} row(s)") + }; + + if ui + .add_enabled( + can_start_export && selected_count > 0, + egui::Button::new(selected_label), + ) + .clicked() + { + let target = ExportTarget::Rows(shared.logs.selected_rows()); + let file_name = export::default_text_file_name(shared); + shared.exports.open_text_dialog( + actions, + target, + export::full_row_text_options(), + EXPORT_DIALOG_ID, + "Export Selected", + file_name, + ); + ui.close(); + } + } + ParserNames::Dlt | ParserNames::SomeIP => { + let selected_label = if selected_count == 0 { + String::from("Export Selected as Table") + } else { + format!("Export {selected_count} row(s) as Table") + }; + + if ui + .add_enabled( + can_start_export && selected_count > 0, + egui::Button::new(selected_label), + ) + .clicked() + { + let schema = Rc::clone(&shared.schema); + let target = ExportTarget::Rows(shared.logs.selected_rows()); + let file_name = export::default_text_file_name(shared); + shared.exports.open_text_modal( + target, + "Export Selected as Table", + schema.as_ref(), + EXPORT_DIALOG_ID, + file_name, + ); + ui.close(); + } + } + } + + let can_start_raw = shared.get_info().raw_export_supported() && can_start_export; + let selected_export_label = if selected_count == 0 { + String::from("Export Selected as Raw") + } else { + format!("Export Selected Rows ({selected_count}) as Raw") + }; + + if ui + .add_enabled( + can_start_raw && selected_count > 0, + egui::Button::new(selected_export_label), + ) + .clicked() + { + let target = ExportTarget::Rows(shared.logs.selected_rows()); + let file_name = export::default_raw_file_name(shared); + shared.exports.open_raw_dialog( + actions, + target, + EXPORT_DIALOG_ID, + "Export Selected as Raw", + file_name, + ); + ui.close(); + } + + ui.separator(); + + match shared.get_info().parser { + ParserNames::Text | ParserNames::Plugins => { + if ui + .add_enabled( + can_start_export && indexed_count > 0, + egui::Button::new("Export Search Results"), + ) + .clicked() + { + let file_name = export::default_text_file_name(shared); + shared.exports.open_text_dialog( + actions, + ExportTarget::Indexed, + export::full_row_text_options(), + EXPORT_DIALOG_ID, + "Export Search Results", + file_name, + ); + ui.close(); + } + } + ParserNames::Dlt | ParserNames::SomeIP => { + if ui + .add_enabled( + can_start_export && indexed_count > 0, + egui::Button::new("Export Search Results as Table"), + ) + .clicked() + { + let schema = Rc::clone(&shared.schema); + let file_name = export::default_text_file_name(shared); + shared.exports.open_text_modal( + ExportTarget::Indexed, + "Export Search Results as Table", + schema.as_ref(), + EXPORT_DIALOG_ID, + file_name, + ); + ui.close(); + } + } + } + + if ui + .add_enabled( + can_start_raw && indexed_count > 0, + egui::Button::new("Export Search Results as Raw"), + ) + .clicked() + { + let file_name = export::default_raw_file_name(shared); + shared.exports.open_raw_dialog( + actions, + ExportTarget::Indexed, + EXPORT_DIALOG_ID, + "Export Search Results as Raw", + file_name, + ); + ui.close(); + } + + let origins = shared + .observe + .operations() + .iter() + .map(|operation| &operation.origin); + let mode = SearchResultsTabMode::resolve_from(shared.get_info().parser, origins); + if ui + .add_enabled( + can_start_export && indexed_count > 0, + egui::Button::new(mode.context_menu_label()), + ) + .clicked() + { + let operation_id = uuid::Uuid::new_v4(); + let restore_state = capture_state_snapshot(shared, registry, false); + shared.exports.track_search_results_tab(operation_id); + if !actions.try_send_command( + &self.cmd_tx, + SessionCommand::OpenSearchResultsAsNewTab { + operation_id, + restore_state, + }, + ) { + shared.exports.clear_operation(operation_id); + } + ui.close(); + } + } + /// Queues a vertical table scroll for the next render pass. pub fn scroll(&mut self, action: TableScroll, row_count: u64) { if let Some(target) = common::log_table::table::scroll_target( @@ -155,6 +359,7 @@ struct LogsDelegate<'a> { table: &'a mut SearchTable, shared: &'a mut SessionShared, actions: &'a mut UiActions, + registry: &'a FilterRegistry, request_repaint: bool, has_multi_sources: bool, /// Dedupes row/background and child-widget clicks for the same row in one pass. @@ -167,12 +372,14 @@ impl<'a> LogsDelegate<'a> { table: &'a mut SearchTable, shared: &'a mut SessionShared, actions: &'a mut UiActions, + registry: &'a FilterRegistry, ) -> Self { let has_multi_sources = shared.observe.sources_count() > 1; Self { table, shared, actions, + registry, request_repaint: false, has_multi_sources, handled_selection_click_row: None, @@ -252,6 +459,11 @@ impl<'a> LogsDelegate<'a> { attachment_info, ); + header.response.context_menu(|ui| { + self.table + .render_context_menu(self.shared, self.actions, self.registry, ui) + }); + if header.attachment_clicked { if let Some(attachment_id) = attachment_id && let Some(attachment) = self @@ -304,7 +516,10 @@ impl<'a> LogsDelegate<'a> { self.table.last_visible_rows = None; } - ui.label("Loading..."); + ui.label("Loading...").context_menu(|ui| { + self.table + .render_context_menu(self.shared, self.actions, self.registry, ui) + }); return; } }; @@ -322,6 +537,11 @@ impl<'a> LogsDelegate<'a> { if response.clicked() { self.handle_selection_click(log_item.element.pos as u64, ui.input(|i| i.modifiers)); } + + response.context_menu(|ui| { + self.table + .render_context_menu(self.shared, self.actions, self.registry, ui) + }); }); if source_changed { @@ -416,11 +636,16 @@ impl TableDelegate for LogsDelegate<'_> { let main_log_pos = log_item.map(|item| item.element.pos as u64); common::log_table::table::apply_log_row_colors(ui, self.shared, main_log_pos, is_selected); - if ui.response().interact(Sense::click()).clicked() + let response = ui.response().interact(Sense::click()); + if response.clicked() && let Some(log_item) = log_item { self.handle_selection_click(log_item.element.pos as u64, ui.input(|i| i.modifiers)); } + response.context_menu(|ui| { + self.table + .render_context_menu(self.shared, self.actions, self.registry, ui) + }); } fn cell_ui(&mut self, ui: &mut Ui, cell: &CellInfo) { diff --git a/application/apps/indexer/gui/application/src/session/ui/common/log_table/table.rs b/application/apps/indexer/gui/application/src/session/ui/common/log_table/table.rs index 5acb9ea4d3..0252ccfc87 100644 --- a/application/apps/indexer/gui/application/src/session/ui/common/log_table/table.rs +++ b/application/apps/indexer/gui/application/src/session/ui/common/log_table/table.rs @@ -64,6 +64,26 @@ pub enum TableScroll { Bottom, } +/// Renders the shared context-menu command for clearing log selection. +pub fn render_unselect_action(shared: &mut SessionShared, ui: &mut Ui) { + let selected_count = shared.logs.selected_count(); + let label = if selected_count == 0 { + String::from("Unselect All") + } else { + format!("Unselect {selected_count} row(s)") + }; + + if ui + .add_enabled(selected_count > 0, egui::Button::new(label)) + .clicked() + { + shared.logs.clear_selection(); + ui.close(); + } + + ui.separator(); +} + /// Returns the row range to bring into view for a table scroll action. pub fn scroll_target( action: TableScroll, diff --git a/application/apps/indexer/gui/application/src/session/ui/common/log_table/text.rs b/application/apps/indexer/gui/application/src/session/ui/common/log_table/text.rs index 4c6f567e29..3470d9f5f7 100644 --- a/application/apps/indexer/gui/application/src/session/ui/common/log_table/text.rs +++ b/application/apps/indexer/gui/application/src/session/ui/common/log_table/text.rs @@ -407,6 +407,7 @@ mod tests { id: session_id, title: "test".to_owned(), parser: ParserNames::Text, + raw_export_supported: false, }; SessionShared::new(session_info, observe_op) diff --git a/application/apps/indexer/gui/application/src/session/ui/common/logs_mapped.rs b/application/apps/indexer/gui/application/src/session/ui/common/logs_mapped.rs index 5680de8ef4..d50f76b1af 100644 --- a/application/apps/indexer/gui/application/src/session/ui/common/logs_mapped.rs +++ b/application/apps/indexer/gui/application/src/session/ui/common/logs_mapped.rs @@ -149,7 +149,7 @@ mod tests { fn prepare_log(&self, element: &mut GrabbedElement) -> Vec> { let mut ranges = Vec::with_capacity(self.columns.len()); - map_columns_with_separator(&element.content, &mut ranges, '|'); + map_columns_with_separator(&element.content, &mut ranges, "|"); ranges } } diff --git a/application/apps/indexer/gui/application/src/session/ui/definitions/schema/mod.rs b/application/apps/indexer/gui/application/src/session/ui/definitions/schema/mod.rs index 5634e9a735..7e47b306aa 100644 --- a/application/apps/indexer/gui/application/src/session/ui/definitions/schema/mod.rs +++ b/application/apps/indexer/gui/application/src/session/ui/definitions/schema/mod.rs @@ -60,18 +60,15 @@ pub fn from_parser(parser: ParserNames) -> Rc { } } -/// Helper function to map columns based on a specific character separator. -pub fn map_columns_with_separator(log: &str, ranges: &mut Vec>, separtor: char) { +/// Helper function to map columns based on a specific string separator. +pub fn map_columns_with_separator(log: &str, ranges: &mut Vec>, separator: &str) { let mut start_index = 0; - for (idx, text) in log.match_indices(separtor) { + for (idx, text) in log.match_indices(separator) { ranges.push(start_index..idx); - - // Advance start past the separator start_index = idx + text.len(); } - // Push the final column (everything after the last separator) ranges.push(start_index..log.len()); } @@ -86,7 +83,7 @@ mod tests { #[test] fn test_consecutive_separators_empty_columns() { let log = "VAL1||VAL2|||VAL3"; - let sep = '|'; + let sep = "|"; let mut ranges = Vec::new(); map_columns_with_separator(log, &mut ranges, sep); @@ -106,7 +103,7 @@ mod tests { #[test] fn test_leading_and_trailing_separators() { let log = "|VAL1|VAL2|"; - let sep = '|'; + let sep = "|"; let mut ranges = Vec::new(); map_columns_with_separator(log, &mut ranges, sep); @@ -119,7 +116,7 @@ mod tests { #[test] fn test_only_separators() { let log = "||"; - let sep = '|'; + let sep = "|"; let mut ranges = Vec::new(); map_columns_with_separator(log, &mut ranges, sep); @@ -132,7 +129,7 @@ mod tests { #[test] fn test_empty_string() { let log = ""; - let sep = '|'; + let sep = "|"; let mut ranges = Vec::new(); map_columns_with_separator(log, &mut ranges, sep); @@ -146,7 +143,7 @@ mod tests { #[test] fn test_no_separator_present() { let log = "WHOLE_LINE"; - let sep = '|'; + let sep = "|"; let mut ranges = Vec::new(); map_columns_with_separator(log, &mut ranges, sep); @@ -158,21 +155,15 @@ mod tests { #[test] fn test_multibyte_separator() { - // Using a 4-byte character as separator: 🚀 - let log = "A🚀B"; - let sep = '🚀'; + let log = "A€B"; + let sep = "€"; let mut ranges = Vec::new(); map_columns_with_separator(log, &mut ranges, sep); let slices = get_slices(log, &ranges); assert_eq!(slices, vec!["A", "B"]); - - // Check indices to ensure we skipped the 4 bytes of the character correctly - // "A" is byte 0. - // "🚀" is bytes 1,2,3,4. - // "B" starts at byte 5. - assert_eq!(ranges[0], 0..1); // "A" - assert_eq!(ranges[1], 5..6); // "B" + assert_eq!(ranges[0], 0..1); + assert_eq!(ranges[1], 4..5); } } diff --git a/application/apps/indexer/gui/application/src/session/ui/export_modal.rs b/application/apps/indexer/gui/application/src/session/ui/export_modal.rs new file mode 100644 index 0000000000..b988cd14be --- /dev/null +++ b/application/apps/indexer/gui/application/src/session/ui/export_modal.rs @@ -0,0 +1,146 @@ +use egui::{ + Align, Button, Frame, Label, Layout, Margin, RichText, ScrollArea, Ui, Widget as _, vec2, +}; + +use crate::{ + common::modal::{ModalSize, ResponsiveModalSize, show_modal}, + host::ui::UiActions, + session::ui::shared::export::{ExportState, TextExportModalState, TextExportValidationError}, +}; + +const ACTION_BUTTON_SIZE: egui::Vec2 = vec2(90.0, 25.0); + +/// Renders the pending text table export modal and advances its workflow on confirmation. +pub fn render_content(exports: &mut ExportState, actions: &mut UiActions, parent_ui: &Ui) { + let Some(mut modal_state) = exports.take_text_modal() else { + return; + }; + + let mut export = false; + let mut can_export = false; + + let modal = show_modal( + parent_ui, + "text_export_modal", + ModalSize::Responsive(ResponsiveModalSize { + width_ratio: 0.5, + height_ratio: 0.90, + min_size: vec2(420.0, 280.0), + max_size: vec2(600.0, 670.0), + window_padding: vec2(20.0, 20.0), + }), + |ui, _size| { + ui.vertical_centered(|ui| { + ui.heading(modal_state.title); + }); + ui.add_space(8.0); + + const RESERVED_FOOTER_HEIGHT: f32 = ACTION_BUTTON_SIZE.y + 24.0; + const COLUMN_LIST_MIN_HEIGHT: f32 = 120.0; + + // Only the column list scrolls; the action buttons must stay visible. + let column_list_height = (ui.available_height() - RESERVED_FOOTER_HEIGHT) + .max(COLUMN_LIST_MIN_HEIGHT) + .min(ui.available_height()); + + // Keep this as exactly two egui columns for DLT/SomeIP table export. + ui.columns_const(|[col1, col2]| { + col1.vertical(|ui| { + ScrollArea::vertical() + .max_height(column_list_height) + .auto_shrink([false, false]) + .show(ui, |ui| render_column_rows(ui, &mut modal_state)); + }); + + col2.vertical(|ui| { + ui.label( + "Please select columns to be exported and define the delimiter, which will be used to split columns.", + ); + ui.add_space(12.0); + ui.label(RichText::new("Delimiter").strong()); + ui.text_edit_singleline(&mut modal_state.delimiter); + ui.label(format!("Delimiter \"{}\"", modal_state.delimiter)); + ui.add_space(4.0); + + let validation_errors = modal_state.validation_errors(); + can_export = validation_errors.is_empty(); + for error in validation_errors { + ui.colored_label(ui.visuals().warn_fg_color, validation_error_text(error)); + } + }); + }); + + ui.add_space(12.0); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if Button::new("Cancel") + .min_size(ACTION_BUTTON_SIZE) + .ui(ui) + .clicked() + { + ui.close(); + } + + if ui + .add_enabled( + can_export, + Button::new("Export").min_size(ACTION_BUTTON_SIZE), + ) + .clicked() + { + export = true; + ui.close(); + } + }); + }, + ); + + if export { + exports.export_text_modal(actions, modal_state); + } else if !modal.should_close() { + exports.keep_text_modal(modal_state); + } +} + +fn render_column_rows(ui: &mut Ui, modal_state: &mut TextExportModalState) { + const COLUMN_ROW_MARGIN: Margin = Margin::symmetric(6, 4); + + for column in &mut modal_state.columns { + Frame::group(ui.style()) + .fill(ui.visuals().faint_bg_color) + .outer_margin(Margin::symmetric(10, 2)) + .inner_margin(COLUMN_ROW_MARGIN) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + const COLUMN_TEXT_SPACING: f32 = 1.0; + let text_height = + ui.text_style_height(&egui::TextStyle::Body) * 2.0 + COLUMN_TEXT_SPACING; + let checkbox_height = ui.spacing().interact_size.y; + ui.allocate_ui(vec2(ui.spacing().interact_size.x, text_height), |ui| { + ui.add_space(((text_height - checkbox_height) * 0.5).max(0.0)); + ui.checkbox(&mut column.selected, ""); + }); + + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = COLUMN_TEXT_SPACING; + Label::new(RichText::new(column.label.as_str()).strong()) + .truncate() + .ui(ui); + Label::new(RichText::new(column.tooltip.as_str())) + .truncate() + .ui(ui); + }); + }); + }); + } +} + +fn validation_error_text(error: TextExportValidationError) -> &'static str { + match error { + TextExportValidationError::NoColumnsSelected => "Select at least one column.", + TextExportValidationError::EmptyDelimiter => "Delimiter cannot be empty.", + TextExportValidationError::DelimiterContainsNewline => { + "Delimiter cannot contain line breaks." + } + } +} diff --git a/application/apps/indexer/gui/application/src/session/ui/logs_table/mod.rs b/application/apps/indexer/gui/application/src/session/ui/logs_table/mod.rs index 34f6ee4a57..4c9c48c75b 100644 --- a/application/apps/indexer/gui/application/src/session/ui/logs_table/mod.rs +++ b/application/apps/indexer/gui/application/src/session/ui/logs_table/mod.rs @@ -1,19 +1,23 @@ use std::{ ops::{Range, RangeInclusive}, rc::Rc, + sync::mpsc::Receiver as StdReceiver, }; use egui::{Sense, TextBuffer, Ui}; use egui_table::{CellInfo, HeaderCellInfo, PrefetchInfo, TableDelegate}; +use tokio::sync::mpsc::Sender; + use processor::grabber::LineRange; -use std::sync::mpsc::Receiver as StdReceiver; use stypes::GrabbedElement; -use tokio::sync::mpsc::Sender; use crate::{ - host::ui::{UiActions, state::PanelsVisibility}, + host::{ + common::parsers::ParserNames, + ui::{UiActions, state::PanelsVisibility}, + }, session::{ - command::SessionCommand, + command::{ExportTarget, SessionCommand}, error::SessionError, types::attachment::{PreviewKind, PreviewRequest, PreviewTarget, kind_for_mime}, ui::{ @@ -32,12 +36,13 @@ use crate::{ logs_mapped::LogsMapped, }, definitions::schema::LogSchema, - shared::{SearchTableSync, SessionShared}, + shared::{SearchTableSync, SessionShared, export}, }, }, }; const TABLE_ID_SALT: &str = "logs_table"; +const EXPORT_DIALOG_ID: &str = "logs_table_export"; /// This is used for storing some attachment state during rendering of the logs table only. pub enum LogAttachmentInfo { @@ -125,6 +130,11 @@ impl LogsTable { } let mut delegate = LogsDelegate::new(self, shared, actions, search_table_visible); let response = table.show(ui, &mut delegate); + response.context_menu(|ui| { + delegate + .table + .render_context_menu(delegate.shared, delegate.actions, ui); + }); if delegate.request_repaint { ui.request_repaint(); @@ -136,6 +146,142 @@ impl LogsTable { sync_column_widths(ui, TABLE_ID_SALT, &mut shared.view.log_columns); } + fn render_context_menu( + &mut self, + shared: &mut SessionShared, + actions: &mut UiActions, + ui: &mut Ui, + ) { + table::render_unselect_action(shared, ui); + + let selected_count = shared.logs.selected_count(); + let can_start_export = shared.exports.can_start(); + + match shared.get_info().parser { + ParserNames::Text | ParserNames::Plugins => { + let selected_label = if selected_count == 0 { + String::from("Export Selected") + } else { + format!("Export {selected_count} row(s)") + }; + + if ui + .add_enabled( + can_start_export && selected_count > 0, + egui::Button::new(selected_label), + ) + .clicked() + { + let target = ExportTarget::Rows(shared.logs.selected_rows()); + let file_name = export::default_text_file_name(shared); + shared.exports.open_text_dialog( + actions, + target, + export::full_row_text_options(), + EXPORT_DIALOG_ID, + "Export Selected", + file_name, + ); + ui.close(); + } + } + ParserNames::Dlt | ParserNames::SomeIP => { + let selected_label = if selected_count == 0 { + String::from("Export Selected as Table") + } else { + format!("Export {selected_count} row(s) as Table") + }; + + if ui + .add_enabled( + can_start_export && selected_count > 0, + egui::Button::new(selected_label), + ) + .clicked() + { + let schema = Rc::clone(&self.schema); + let target = ExportTarget::Rows(shared.logs.selected_rows()); + let file_name = export::default_text_file_name(shared); + shared.exports.open_text_modal( + target, + "Export Selected as Table", + schema.as_ref(), + EXPORT_DIALOG_ID, + file_name, + ); + ui.close(); + } + } + } + + let can_export_raw = + shared.get_info().raw_export_supported() && can_start_export && selected_count > 0; + let selected_export_label = if selected_count == 0 { + String::from("Export Selected as Raw") + } else { + format!("Export Selected Rows ({selected_count}) as Raw") + }; + + if ui + .add_enabled(can_export_raw, egui::Button::new(selected_export_label)) + .clicked() + { + let target = ExportTarget::Rows(shared.logs.selected_rows()); + let file_name = export::default_raw_file_name(shared); + shared.exports.open_raw_dialog( + actions, + target, + EXPORT_DIALOG_ID, + "Export Selected as Raw", + file_name, + ); + ui.close(); + } + + match shared.get_info().parser { + ParserNames::Text | ParserNames::Plugins => { + if ui + .add_enabled( + can_start_export && shared.logs.logs_count > 0, + egui::Button::new("Export All Logs"), + ) + .clicked() + { + let file_name = export::default_text_file_name(shared); + shared.exports.open_text_dialog( + actions, + ExportTarget::All, + export::full_row_text_options(), + EXPORT_DIALOG_ID, + "Export All Logs", + file_name, + ); + ui.close(); + } + } + ParserNames::Dlt | ParserNames::SomeIP => { + if ui + .add_enabled( + can_start_export && shared.logs.logs_count > 0, + egui::Button::new("Export All as Table"), + ) + .clicked() + { + let schema = Rc::clone(&self.schema); + let file_name = export::default_text_file_name(shared); + shared.exports.open_text_modal( + ExportTarget::All, + "Export All as Table", + schema.as_ref(), + EXPORT_DIALOG_ID, + file_name, + ); + ui.close(); + } + } + } + } + /// Queues a vertical table scroll for the next render pass. pub fn scroll(&mut self, action: TableScroll, row_count: u64) { if let Some(target) = @@ -261,6 +407,11 @@ impl<'a> LogsDelegate<'a> { attachment_info, ); + header.response.context_menu(|ui| { + self.table + .render_context_menu(self.shared, self.actions, ui) + }); + if header.attachment_clicked { if let Some(attachment_id) = attachment_id && let Some(attachment) = self @@ -314,6 +465,10 @@ impl<'a> LogsDelegate<'a> { } let response = ui.monospace("Loading..."); + response.context_menu(|ui| { + self.table + .render_context_menu(self.shared, self.actions, ui) + }); if response.clicked() { self.handle_selection_click(row_nr, ui.input(|i| i.modifiers)); } @@ -333,6 +488,11 @@ impl<'a> LogsDelegate<'a> { if response.clicked() { self.handle_selection_click(row_nr, ui.input(|i| i.modifiers)); } + + response.context_menu(|ui| { + self.table + .render_context_menu(self.shared, self.actions, ui) + }); }); if source_changed { @@ -427,9 +587,14 @@ impl TableDelegate for LogsDelegate<'_> { let is_selected = self.shared.logs.is_selected(row_nr); common::log_table::table::apply_log_row_colors(ui, self.shared, Some(row_nr), is_selected); - if ui.response().interact(Sense::click()).clicked() { + let response = ui.response().interact(Sense::click()); + if response.clicked() { self.handle_selection_click(row_nr, ui.input(|i| i.modifiers)); } + response.context_menu(|ui| { + self.table + .render_context_menu(self.shared, self.actions, ui) + }); } fn cell_ui(&mut self, ui: &mut egui::Ui, cell: &egui_table::CellInfo) { diff --git a/application/apps/indexer/gui/application/src/session/ui/mod.rs b/application/apps/indexer/gui/application/src/session/ui/mod.rs index e340a62198..da56097e1a 100644 --- a/application/apps/indexer/gui/application/src/session/ui/mod.rs +++ b/application/apps/indexer/gui/application/src/session/ui/mod.rs @@ -38,7 +38,8 @@ use side_panel::{SidePanelUi, SideTabType}; mod attachment_modal; mod bottom_panel; mod common; -mod definitions; +pub mod definitions; +mod export_modal; mod logs_table; mod recent; mod shared; @@ -72,8 +73,7 @@ impl Session { observe_op, } = init; let RecentSessionRuntimeInit { - source_key: recent_source_key, - supports_bookmarks, + tracking, additional_observe_ops, } = recent_runtime; @@ -91,12 +91,16 @@ impl Session { host_cmd_tx, Rc::clone(&shared.schema), ); + let recent_session = match tracking { + Some(init) => RecentSessionRuntime::new(init.source_key, init.supports_bookmarks), + None => RecentSessionRuntime::untracked(), + }; Self { receivers, side_panel, shared, - recent_session: RecentSessionRuntime::new(recent_source_key, supports_bookmarks), + recent_session, logs_table, bottom_panel, attachment_modal: attachment_modal::AttachmentModalUi::new(), @@ -116,6 +120,7 @@ impl Session { ui: &mut Ui, ) { let Self { + cmd_tx, logs_table, bottom_panel, side_panel, @@ -128,13 +133,9 @@ impl Session { "Signals leaked from previous frame." ); - if shared.observe.show_startup_spinner(shared.logs.logs_count) { - show_busy_indicator( - ui, - Some("Initializing Session"), - Some(|| actions.add_host_action(HostAction::CloseSession(shared.get_id()))), - ); - } + shared.exports.handle_dialogs(actions, cmd_tx); + + Self::render_busy_indicator(shared, actions, ui); Panel::bottom("status_bar") .resizable(false) @@ -174,12 +175,30 @@ impl Session { }); }); + export_modal::render_content(&mut shared.exports, actions, ui); + self.attachment_modal .render_content(&mut shared.attachments, ui); self.handle_signals(registry, panels_visibility); } + fn render_busy_indicator(shared: &SessionShared, actions: &mut UiActions, ui: &Ui) { + if shared.observe.show_startup_spinner(shared.logs.logs_count) { + show_busy_indicator( + ui, + Some("Initializing Session"), + Some(|| actions.add_host_action(HostAction::CloseSession(shared.get_id()))), + ); + + return; + } + + if let Some(label) = shared.exports.busy_label() { + show_busy_indicator(ui, Some(label), Option::::None); + } + } + /// Processes frame-local signals queued by child session components. fn handle_signals( &mut self, @@ -315,7 +334,11 @@ impl Session { operation_id, phase, } => { - if self.shared.update_operation(operation_id, phase).consumed() { + if self + .shared + .update_operation(operation_id, phase, actions) + .consumed() + { continue; } // Potential components which keep track for operations can go here. diff --git a/application/apps/indexer/gui/application/src/session/ui/recent.rs b/application/apps/indexer/gui/application/src/session/ui/recent.rs index 8e414bbb08..549a48d959 100644 --- a/application/apps/indexer/gui/application/src/session/ui/recent.rs +++ b/application/apps/indexer/gui/application/src/session/ui/recent.rs @@ -40,6 +40,16 @@ impl RecentSessionRuntime { } } + /// Creates a live-only session that never writes recent-session storage. + pub fn untracked() -> Self { + Self { + source_key: None, + supports_bookmarks: false, + last_revision: 0, + pending_bookmark_restore: false, + } + } + /// Applies restored recent-session state through the normal session and registry path. pub fn apply_restore( &mut self, @@ -163,7 +173,7 @@ impl RecentSessionRuntime { } /// Captures the persisted recent-session state from the current live session state. -fn capture_state_snapshot( +pub fn capture_state_snapshot( shared: &SessionShared, registry: &FilterRegistry, supports_bookmarks: bool, @@ -236,6 +246,7 @@ mod tests { id: Uuid::new_v4(), title: String::from("test"), parser: ParserNames::Text, + raw_export_supported: false, }; let observe_op = ObserveOperation::new(Uuid::new_v4(), origin); SessionShared::new(session_info, observe_op) @@ -449,4 +460,22 @@ mod tests { assert!(state.bookmarks.is_empty()); } + + #[test] + fn untracked_skips_state_updates() { + let mut shared = new_shared(ObserveOrigin::File( + String::from("source"), + FileFormat::Text, + PathBuf::from("source.log"), + )); + let mut registry = FilterRegistry::default(); + let mut recent = RecentSessionRuntime::untracked(); + + let filter_id = registry.add_filter(FilterDefinition::new(SearchFilter::plain("first"))); + shared.apply_filter(&mut registry, filter_id); + shared.insert_bookmark(4); + + assert!(recent.take_state_update(&shared, ®istry).is_none()); + assert!(recent.source_key().is_none()); + } } diff --git a/application/apps/indexer/gui/application/src/session/ui/shared/export/file_dialog.rs b/application/apps/indexer/gui/application/src/session/ui/shared/export/file_dialog.rs new file mode 100644 index 0000000000..43b85e806d --- /dev/null +++ b/application/apps/indexer/gui/application/src/session/ui/shared/export/file_dialog.rs @@ -0,0 +1,248 @@ +//! File-dialog workflow and default destination names for session exports. +//! +//! Dialogs return asynchronously, so this module stores the selected export target +//! until the matching save dialog completes. + +use std::{ops::Not, path::PathBuf}; + +use stypes::ObserveOrigin; +use tokio::sync::mpsc::Sender; +use uuid::Uuid; + +use crate::{ + host::ui::{UiActions, actions::FileDialogOptions}, + session::{ + command::{ExportTarget, SessionCommand, TextExportOptions}, + ui::shared::SessionShared, + }, +}; + +use super::ExportState; + +/// Raw export data waiting for its save dialog result. +#[derive(Debug)] +pub struct PendingRawExport { + /// Rows or indexed/all range to export after a destination is chosen. + target: ExportTarget, + /// Save dialog id used to collect the matching async output. + dialog_id: &'static str, +} + +/// Text export data waiting for its save dialog result. +#[derive(Debug)] +pub struct PendingTextExport { + /// Rows or indexed/all range to export after a destination is chosen. + target: ExportTarget, + /// Text export formatting options selected before the save dialog opened. + options: TextExportOptions, + /// Save dialog id used to collect the matching async output. + dialog_id: &'static str, +} + +/// Result of polling an async save dialog for a selected destination. +enum DialogDestination { + /// Dialog has not produced output yet. + Pending, + /// Dialog completed without a usable destination. + Dismissed, + /// Dialog completed with exactly one destination. + Selected(PathBuf), +} + +impl ExportState { + /// Polls pending export save dialogs and dispatches backend export commands. + pub fn handle_dialogs(&mut self, actions: &mut UiActions, cmd_tx: &Sender) { + self.handle_raw_dialog(actions, cmd_tx); + self.handle_text_dialog(actions, cmd_tx); + } + + /// Opens a raw export save dialog and stores its target until completion. + pub fn open_raw_dialog( + &mut self, + actions: &mut UiActions, + target: ExportTarget, + dialog_id: &'static str, + title: &'static str, + file_name: String, + ) { + self.pending_raw = Some(PendingRawExport { target, dialog_id }); + let options = FileDialogOptions::new().file_name(file_name).title(title); + actions.file_dialog.save_file(dialog_id, options); + } + + /// Opens a text export save dialog and stores its target/options until completion. + pub fn open_text_dialog( + &mut self, + actions: &mut UiActions, + target: ExportTarget, + options: TextExportOptions, + dialog_id: &'static str, + title: &'static str, + file_name: String, + ) { + self.pending_text = Some(PendingTextExport { + target, + options, + dialog_id, + }); + let options = FileDialogOptions::new().file_name(file_name).title(title); + actions.file_dialog.save_file(dialog_id, options); + } + + /// Dispatches a raw export once the pending save dialog returns a destination. + fn handle_raw_dialog(&mut self, actions: &mut UiActions, cmd_tx: &Sender) { + let Some(dialog_id) = self.pending_raw.as_ref().map(|pending| pending.dialog_id) else { + return; + }; + + let destination = match take_dialog_destination(actions, dialog_id, "raw") { + DialogDestination::Pending => return, + DialogDestination::Dismissed => { + self.pending_raw = None; + return; + } + DialogDestination::Selected(destination) => destination, + }; + + let Some(pending) = self.pending_raw.take() else { + log::error!("Missing raw export target"); + return; + }; + + let operation_id = Uuid::new_v4(); + self.track_file_export(operation_id, destination.clone()); + + if !actions.try_send_command( + cmd_tx, + SessionCommand::ExportRaw { + operation_id, + destination, + target: pending.target, + }, + ) { + self.clear_operation(operation_id); + } + } + + /// Dispatches a text export once the pending save dialog returns a destination. + fn handle_text_dialog(&mut self, actions: &mut UiActions, cmd_tx: &Sender) { + let Some(dialog_id) = self.pending_text.as_ref().map(|pending| pending.dialog_id) else { + return; + }; + + let destination = match take_dialog_destination(actions, dialog_id, "text") { + DialogDestination::Pending => return, + DialogDestination::Dismissed => { + self.pending_text = None; + return; + } + DialogDestination::Selected(destination) => destination, + }; + + let Some(pending) = self.pending_text.take() else { + log::error!("Missing text export target"); + return; + }; + + let operation_id = Uuid::new_v4(); + self.track_file_export(operation_id, destination.clone()); + + if !actions.try_send_command( + cmd_tx, + SessionCommand::ExportText { + operation_id, + destination, + target: pending.target, + options: Box::new(pending.options), + }, + ) { + self.clear_operation(operation_id); + } + } +} + +/// Returns the default file name for raw log export dialogs. +pub fn default_raw_file_name(shared: &SessionShared) -> String { + const FALLBACK_FILE_NAME: &str = "indexed_export.bin"; + const SUFFIX: &str = "_export"; + const CONCAT_FILE_STEM: &str = "concat_export"; + + let Some(origin) = shared.observe.operations().first().map(|op| &op.origin) else { + return FALLBACK_FILE_NAME.to_owned(); + }; + + match origin { + ObserveOrigin::File(_, _, path) => { + let Some(file_stem) = path.file_stem().and_then(non_empty_os_str) else { + return FALLBACK_FILE_NAME.to_owned(); + }; + + if let Some(extension) = path.extension().and_then(non_empty_os_str) { + format!("{file_stem}{SUFFIX}.{extension}") + } else { + format!("{file_stem}{SUFFIX}") + } + } + ObserveOrigin::Concat(files) => { + if let Some(extension) = files + .first() + .and_then(|(_, _, path)| path.extension()) + .and_then(non_empty_os_str) + { + format!("{CONCAT_FILE_STEM}.{extension}") + } else { + CONCAT_FILE_STEM.to_owned() + } + } + ObserveOrigin::Stream(..) => FALLBACK_FILE_NAME.to_owned(), + } +} + +/// Returns the default file name for text log export dialogs. +pub fn default_text_file_name(shared: &SessionShared) -> String { + const FALLBACK_FILE_NAME: &str = "indexed_export.txt"; + const SUFFIX: &str = "_export.txt"; + + let Some(origin) = shared.observe.operations().first().map(|op| &op.origin) else { + return FALLBACK_FILE_NAME.to_owned(); + }; + + match origin { + ObserveOrigin::File(_, _, path) => path + .file_stem() + .and_then(non_empty_os_str) + .map(|file_stem| format!("{file_stem}{SUFFIX}")) + .unwrap_or_else(|| FALLBACK_FILE_NAME.to_owned()), + ObserveOrigin::Concat(_) => String::from("concat_export.txt"), + ObserveOrigin::Stream(..) => FALLBACK_FILE_NAME.to_owned(), + } +} + +/// Takes the save dialog output while preserving pending state if it is not ready yet. +fn take_dialog_destination( + actions: &mut UiActions, + dialog_id: &str, + export_kind: &str, +) -> DialogDestination { + let Some(selected_paths) = actions.file_dialog.take_output(dialog_id) else { + return DialogDestination::Pending; + }; + + let mut destinations = selected_paths.into_iter(); + let Some(destination) = destinations.next() else { + return DialogDestination::Dismissed; + }; + + if destinations.next().is_some() { + log::error!("Expected exactly one destination from {export_kind} export dialog"); + return DialogDestination::Dismissed; + } + + DialogDestination::Selected(destination) +} + +/// Converts non-empty OS strings to owned strings for suggested file names. +fn non_empty_os_str(value: &std::ffi::OsStr) -> Option { + let value = value.to_string_lossy(); + value.is_empty().not().then(|| value.into_owned()) +} diff --git a/application/apps/indexer/gui/application/src/session/ui/shared/export/mod.rs b/application/apps/indexer/gui/application/src/session/ui/shared/export/mod.rs new file mode 100644 index 0000000000..bc9003ac6b --- /dev/null +++ b/application/apps/indexer/gui/application/src/session/ui/shared/export/mod.rs @@ -0,0 +1,262 @@ +//! Session-level export workflow state. +//! +//! Tables choose export targets and labels, while this module owns pending dialogs, +//! modal handoff, backend operation tracking, and terminal notifications. + +use std::path::PathBuf; + +use uuid::Uuid; + +use crate::{ + host::{notification::AppNotification, ui::UiActions}, + session::{ + command::{ExportTarget, TextExportOptions}, + types::OperationPhase, + ui::definitions::UpdateOperationOutcome, + }, +}; +use file_dialog::{PendingRawExport, PendingTextExport}; + +mod file_dialog; +mod modal; + +pub use file_dialog::{default_raw_file_name, default_text_file_name}; +pub use modal::{TextExportModalState, TextExportValidationError}; + +/// Export workflow state for one session. +#[derive(Debug, Default)] +pub struct ExportState { + /// Backend export-like operation awaiting a terminal phase. + pending_op: Option, + /// Raw save dialog state awaiting a chosen destination or cancellation. + pending_raw: Option, + /// Text save dialog state awaiting a chosen destination or cancellation. + pending_text: Option, + /// Placeholder text export modal state awaiting user confirmation. + text_modal: Option, +} + +impl ExportState { + /// Returns whether a new export workflow can be opened. + pub fn can_start(&self) -> bool { + self.pending_op.is_none() + && self.pending_raw.is_none() + && self.pending_text.is_none() + && self.text_modal.is_none() + } + + /// Starts tracking a file export operation until a terminal backend phase arrives. + pub fn track_file_export(&mut self, operation_id: Uuid, destination: PathBuf) { + self.pending_op = Some(PendingExportOperation { + operation_id, + kind: PendingExportKind::File { destination }, + }); + } + + /// Starts tracking generated search-results tab preparation. + pub fn track_search_results_tab(&mut self, operation_id: Uuid) { + self.pending_op = Some(PendingExportOperation { + operation_id, + kind: PendingExportKind::SearchResultsTab, + }); + } + + /// Clears the tracked backend operation if it matches the provided id. + pub fn clear_operation(&mut self, operation_id: Uuid) { + self.pending_op + .take_if(|operation| operation.operation_id == operation_id); + } + + /// Returns the blocking indicator label for the active backend operation. + pub fn busy_label(&self) -> Option<&'static str> { + let operation = self.pending_op.as_ref()?; + + let label = match &operation.kind { + PendingExportKind::File { .. } => "Exporting logs...", + PendingExportKind::SearchResultsTab => "Preparing new session...", + }; + + Some(label) + } + + /// Removes pending text modal state so the UI layer can render it. + pub fn take_text_modal(&mut self) -> Option { + self.text_modal.take() + } + + /// Restores text modal state when the modal stays open for another frame. + pub fn keep_text_modal(&mut self, modal: TextExportModalState) { + self.text_modal = Some(modal); + } + + /// Converts confirmed modal state into the text save dialog workflow. + pub fn export_text_modal(&mut self, actions: &mut UiActions, modal: TextExportModalState) { + let Some(request) = modal.export_request() else { + return; + }; + + self.open_text_dialog( + actions, + request.target, + request.options, + request.dialog_id, + request.title, + request.file_name, + ); + } + + /// Opens the DLT/SomeIP text export modal. + pub fn open_text_modal( + &mut self, + target: ExportTarget, + title: &'static str, + schema: &dyn crate::session::ui::definitions::schema::LogSchema, + dialog_id: &'static str, + file_name: String, + ) { + let modal = TextExportModalState::new(target, title, schema, dialog_id, file_name); + self.text_modal = Some(modal); + } + + /// Handles one operation phase update and emits export notifications for terminal phases. + pub fn handle_phase( + &mut self, + operation_id: Uuid, + phase: OperationPhase, + actions: &mut UiActions, + ) -> UpdateOperationOutcome { + let Some(operation) = self.pending_op.as_ref() else { + return UpdateOperationOutcome::None; + }; + if operation.operation_id != operation_id { + return UpdateOperationOutcome::None; + } + + match phase { + OperationPhase::Initializing | OperationPhase::Processing => { + UpdateOperationOutcome::Consumed + } + OperationPhase::Success => { + let Some(operation) = self.pending_op.take() else { + return UpdateOperationOutcome::None; + }; + + match operation.kind { + PendingExportKind::File { destination } => { + actions.add_notification(AppNotification::Info(format!( + "Exported logs to {}", + destination.display() + ))); + } + PendingExportKind::SearchResultsTab => {} + } + + UpdateOperationOutcome::Consumed + } + OperationPhase::Failed => { + self.pending_op = None; + // The service already emits the detailed session error notification. + UpdateOperationOutcome::Consumed + } + OperationPhase::Skipped => { + self.pending_op = None; + actions + .add_notification(AppNotification::Warning(String::from("No rows to export."))); + UpdateOperationOutcome::Consumed + } + } + } +} + +/// Single backend operation that blocks export-related UI until completion. +#[derive(Debug)] +struct PendingExportOperation { + operation_id: Uuid, + kind: PendingExportKind, +} + +#[derive(Debug)] +enum PendingExportKind { + /// User-selected file export; keeps the destination for the success notification. + File { destination: PathBuf }, + /// Generated export used only to create a new search-results session tab. + SearchResultsTab, +} + +/// Creates options that export full rendered rows without column filtering. +pub fn full_row_text_options() -> TextExportOptions { + TextExportOptions::FullRows +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::host::notification::AppNotification; + + fn test_ui_actions() -> (tokio::runtime::Runtime, UiActions) { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime should be created"); + let ui_actions = UiActions::new(runtime.handle().clone()); + + (runtime, ui_actions) + } + + #[test] + fn file_export_success_reports_and_unblocks() { + let (_runtime, mut actions) = test_ui_actions(); + let operation_id = Uuid::new_v4(); + let mut state = ExportState::default(); + + state.track_file_export(operation_id, PathBuf::from("logs.txt")); + + assert!(!state.can_start()); + assert!(state.busy_label().is_some()); + assert!( + state + .handle_phase(operation_id, OperationPhase::Success, &mut actions) + .consumed() + ); + assert!(state.can_start()); + + let notifications = actions.drain_notifications().collect::>(); + assert_eq!(notifications.len(), 1); + assert!(matches!(notifications[0], AppNotification::Info(_))); + } + + #[test] + fn results_tab_success_unblocks_without_export_notification() { + let (_runtime, mut actions) = test_ui_actions(); + let operation_id = Uuid::new_v4(); + let mut state = ExportState::default(); + + state.track_search_results_tab(operation_id); + + assert!(!state.can_start()); + assert!(state.busy_label().is_some()); + assert!( + state + .handle_phase(operation_id, OperationPhase::Success, &mut actions) + .consumed() + ); + assert!(state.can_start()); + assert_eq!(actions.drain_notifications().count(), 0); + } + + #[test] + fn unmatched_operation_update_is_ignored() { + let (_runtime, mut actions) = test_ui_actions(); + let operation_id = Uuid::new_v4(); + let mut state = ExportState::default(); + + state.track_search_results_tab(operation_id); + + assert_eq!( + state.handle_phase(Uuid::new_v4(), OperationPhase::Success, &mut actions), + UpdateOperationOutcome::None + ); + assert!(!state.can_start()); + assert_eq!(actions.drain_notifications().count(), 0); + } +} diff --git a/application/apps/indexer/gui/application/src/session/ui/shared/export/modal.rs b/application/apps/indexer/gui/application/src/session/ui/shared/export/modal.rs new file mode 100644 index 0000000000..e255221eda --- /dev/null +++ b/application/apps/indexer/gui/application/src/session/ui/shared/export/modal.rs @@ -0,0 +1,278 @@ +//! Text export modal workflow data. +//! +//! Rendering lives in `session/ui/export_modal.rs`; this module only stores the +//! pending target and converts confirmed modal state into export options. + +use crate::session::{ + command::{ExportTarget, TextExportOptions}, + ui::definitions::schema::LogSchema, +}; + +const DEFAULT_DELIMITER: &str = ";"; + +/// Pending modal state for DLT/SomeIP text export. +#[derive(Debug)] +pub struct TextExportModalState { + /// Rows or indexed/all range to export after confirmation. + target: ExportTarget, + /// Modal and following save-dialog title. + pub title: &'static str, + /// Snapshot of schema columns available when the modal opened. + pub columns: Vec, + /// Separator used to join exported columns after filtering. + pub delimiter: String, + /// Save dialog id to open after confirmation. + dialog_id: &'static str, + /// Suggested save-dialog file name. + file_name: String, +} + +/// One selectable text-export table column. +#[derive(Debug)] +pub struct TextExportColumn { + index: usize, + pub label: String, + pub tooltip: String, + pub selected: bool, +} + +/// Validation failure for text table-export options. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TextExportValidationError { + NoColumnsSelected, + EmptyDelimiter, + DelimiterContainsNewline, +} + +/// Confirmed text export modal request ready to open a save dialog. +#[derive(Debug)] +pub struct TextExportRequest { + /// Rows or indexed/all range to export. + pub target: ExportTarget, + /// Text export options derived from the modal state. + pub options: TextExportOptions, + /// Save-dialog title. + pub title: &'static str, + /// Save dialog id used to collect the matching async output. + pub dialog_id: &'static str, + /// Suggested save-dialog file name. + pub file_name: String, +} + +impl TextExportModalState { + /// Creates modal state using all columns from the active schema. + pub fn new( + target: ExportTarget, + title: &'static str, + schema: &dyn LogSchema, + dialog_id: &'static str, + file_name: String, + ) -> Self { + let columns = schema + .columns() + .iter() + .enumerate() + .map(|(index, column)| TextExportColumn { + index, + label: column.header.to_string(), + tooltip: column.header_tooltip.to_string(), + selected: true, + }) + .collect(); + + Self { + target, + title, + columns, + delimiter: DEFAULT_DELIMITER.to_owned(), + dialog_id, + file_name, + } + } + + /// Returns current validation failures. + pub fn validation_errors(&self) -> Vec { + let mut errors = Vec::new(); + + if !self.columns.iter().any(|column| column.selected) { + errors.push(TextExportValidationError::NoColumnsSelected); + } + + if self.delimiter.is_empty() { + errors.push(TextExportValidationError::EmptyDelimiter); + } + + if self.delimiter.contains(['\n', '\r']) { + errors.push(TextExportValidationError::DelimiterContainsNewline); + } + + errors + } + + /// Consumes confirmed modal state and creates a save-dialog request. + pub fn export_request(self) -> Option { + if !self.validation_errors().is_empty() { + return None; + } + + let columns = self + .columns + .into_iter() + .filter_map(|column| column.selected.then_some(column.index)) + .collect(); + + Some(TextExportRequest { + target: self.target, + options: TextExportOptions::Table { + columns, + delimiter: self.delimiter, + }, + title: self.title, + dialog_id: self.dialog_id, + file_name: self.file_name, + }) + } +} + +#[cfg(test)] +mod tests { + use std::ops::Range; + + use egui_table::Column; + use stypes::GrabbedElement; + + use super::*; + use crate::session::ui::definitions::schema::ColumnInfo; + + #[derive(Debug)] + struct TestSchema { + columns: [ColumnInfo; 3], + } + + impl Default for TestSchema { + fn default() -> Self { + Self { + columns: [ + ColumnInfo::new("first", "first tip", Column::default()), + ColumnInfo::new("second", "second tip", Column::default()), + ColumnInfo::new("third", "third tip", Column::default()), + ], + } + } + } + + impl LogSchema for TestSchema { + fn has_headers(&self) -> bool { + true + } + + fn columns(&self) -> &[ColumnInfo] { + &self.columns + } + + fn prepare_log(&self, element: &mut GrabbedElement) -> Vec> { + vec![0..element.content.len()] + } + } + + fn modal() -> TextExportModalState { + TextExportModalState::new( + ExportTarget::All, + "Export", + &TestSchema::default(), + "dialog", + "export.txt".to_owned(), + ) + } + + fn table_options(modal: TextExportModalState) -> (Vec, String) { + let request = modal.export_request().expect("valid export request"); + match request.options { + TextExportOptions::Table { columns, delimiter } => (columns, delimiter), + TextExportOptions::FullRows => panic!("expected table export options"), + } + } + + #[test] + fn default_modal_selects_all_schema_columns() { + let modal = modal(); + + let (columns, delimiter) = table_options(modal); + + assert_eq!(columns, vec![0, 1, 2]); + assert_eq!(delimiter, ";"); + } + + #[test] + fn empty_delimiter_blocks_export() { + let mut modal = modal(); + modal.delimiter.clear(); + + assert_eq!( + modal.validation_errors(), + vec![TextExportValidationError::EmptyDelimiter] + ); + assert!(modal.export_request().is_none()); + } + + #[test] + fn newline_delimiter_blocks_export() { + let mut modal = modal(); + modal.delimiter = "a\nb".to_owned(); + + assert_eq!( + modal.validation_errors(), + vec![TextExportValidationError::DelimiterContainsNewline] + ); + assert!(modal.export_request().is_none()); + } + + #[test] + fn carriage_return_delimiter_blocks_export() { + let mut modal = modal(); + modal.delimiter = "a\rb".to_owned(); + + assert_eq!( + modal.validation_errors(), + vec![TextExportValidationError::DelimiterContainsNewline] + ); + assert!(modal.export_request().is_none()); + } + + #[test] + fn whitespace_delimiter_is_accepted() { + let mut modal = modal(); + modal.delimiter = " ".to_owned(); + + let (columns, delimiter) = table_options(modal); + + assert_eq!(columns, vec![0, 1, 2]); + assert_eq!(delimiter, " "); + } + + #[test] + fn selected_subset_exports_indexes_in_schema_order() { + let mut modal = modal(); + modal.columns[0].selected = false; + modal.columns[2].selected = false; + + let (columns, delimiter) = table_options(modal); + + assert_eq!(columns, vec![1]); + assert_eq!(delimiter, ";"); + } + + #[test] + fn no_selected_columns_blocks_export() { + let mut modal = modal(); + for column in &mut modal.columns { + column.selected = false; + } + + assert_eq!( + modal.validation_errors(), + vec![TextExportValidationError::NoColumnsSelected] + ); + assert!(modal.export_request().is_none()); + } +} diff --git a/application/apps/indexer/gui/application/src/session/ui/shared/info.rs b/application/apps/indexer/gui/application/src/session/ui/shared/info.rs index fefedb1474..a7aa178f4c 100644 --- a/application/apps/indexer/gui/application/src/session/ui/shared/info.rs +++ b/application/apps/indexer/gui/application/src/session/ui/shared/info.rs @@ -1,3 +1,4 @@ +use session_core::state::is_raw_export_available_for; use stypes::{ObserveOptions, ObserveOrigin, Transport}; use uuid::Uuid; @@ -8,6 +9,7 @@ pub struct SessionInfo { pub id: Uuid, pub title: String, pub parser: ParserNames, + pub raw_export_supported: bool, } impl SessionInfo { @@ -27,8 +29,18 @@ impl SessionInfo { }; let parser = ParserNames::from(&options.parser); + let raw_export_supported = is_raw_export_available_for(std::slice::from_ref(options)); - Self { title, id, parser } + Self { + title, + id, + parser, + raw_export_supported, + } + } + + pub fn raw_export_supported(&self) -> bool { + self.raw_export_supported } pub fn update_title(&mut self, state: &ObserveState) { diff --git a/application/apps/indexer/gui/application/src/session/ui/shared/logs.rs b/application/apps/indexer/gui/application/src/session/ui/shared/logs.rs index 42ddbde6c6..18c2e6a7ff 100644 --- a/application/apps/indexer/gui/application/src/session/ui/shared/logs.rs +++ b/application/apps/indexer/gui/application/src/session/ui/shared/logs.rs @@ -87,6 +87,19 @@ impl LogsState { self.selected_rows.len() } + /// Clears all selected rows. + pub fn clear_selection(&mut self) { + self.selected_rows.clear(); + self.last_selected_row = None; + } + + /// Returns selected stream positions without export-specific normalization. + pub fn selected_rows(&self) -> Vec { + // Export range preparation sorts and dedups in the session service. + // No need to sort in UI thread. + self.selected_rows.iter().copied().collect() + } + /// Returns the selected row only when the selection is singular. pub fn single_selected_row(&self) -> Option { if self.selected_rows.len() == 1 { @@ -319,6 +332,19 @@ mod tests { assert_eq!(state.last_selected_row, None); } + #[test] + fn clear_selection_removes_selection_anchor() { + let mut state = LogsState::default(); + + state.replace_selection_with(3); + state.select_from_click(5, EXTEND_RANGE); + state.clear_selection(); + + assert_eq!(state.selected_count(), 0); + assert_eq!(state.single_selected_row(), None); + assert_eq!(state.last_selected_row, None); + } + #[test] fn toggle_row_keeps_remaining_single_selection_without_jump() { let mut state = LogsState::default(); diff --git a/application/apps/indexer/gui/application/src/session/ui/shared/mod.rs b/application/apps/indexer/gui/application/src/session/ui/shared/mod.rs index 2778c53296..9abb5bf130 100644 --- a/application/apps/indexer/gui/application/src/session/ui/shared/mod.rs +++ b/application/apps/indexer/gui/application/src/session/ui/shared/mod.rs @@ -1,7 +1,7 @@ use std::rc::Rc; use crate::{ - host::ui::registry::filters::FilterRegistry, + host::ui::{UiActions, registry::filters::FilterRegistry}, session::{ command::SessionCommand, types::{ObserveOperation, OperationPhase}, @@ -16,6 +16,7 @@ use uuid::Uuid; use super::{bottom_panel::BottomTabType, side_panel::SideTabType}; mod attachments; +pub mod export; mod info; mod logs; mod observe; @@ -24,6 +25,7 @@ mod signal; mod view; pub use attachments::{AttachmentModalState, AttachmentsState}; +pub use export::ExportState; pub use info::SessionInfo; pub use logs::{BookmarkNavigation, LogsState, SearchTableSync, SelectionChange, SelectionIntent}; pub use observe::ObserveState; @@ -56,6 +58,8 @@ pub struct SessionShared { pub attachments: AttachmentsState, + pub exports: ExportState, + pub schema: Rc, /// Monotonic change marker for recent-session state updates. @@ -89,6 +93,7 @@ impl SessionShared { view: UiViewState::new(schema.as_ref()), observe: ObserveState::new(observe_op), attachments: AttachmentsState::default(), + exports: ExportState::default(), schema, recent_revision: 0, } @@ -119,6 +124,7 @@ impl SessionShared { &mut self, operation_id: Uuid, phase: OperationPhase, + actions: &mut UiActions, ) -> UpdateOperationOutcome { // Ensure to update this method when new fields are added. let Self { @@ -133,6 +139,7 @@ impl SessionShared { view: _, observe, attachments: _, + exports, schema: _, recent_revision: _, } = self; @@ -145,7 +152,14 @@ impl SessionShared { return UpdateOperationOutcome::Consumed; } - search_values.update_operation(operation_id, phase) + if search_values + .update_operation(operation_id, phase) + .consumed() + { + return UpdateOperationOutcome::Consumed; + } + + exports.handle_phase(operation_id, phase, actions) } /// Synchronizes UI state with backend search pipelines and returns the commands to dispatch. @@ -415,6 +429,7 @@ mod tests { id: session_id, title: "test".to_owned(), parser: ParserNames::Text, + raw_export_supported: false, }; SessionShared::new(session_info, observe_op) diff --git a/application/apps/indexer/gui/application/src/session/ui/shared/searching/search.rs b/application/apps/indexer/gui/application/src/session/ui/shared/searching/search.rs index b63b7695f8..e14443cb0e 100644 --- a/application/apps/indexer/gui/application/src/session/ui/shared/searching/search.rs +++ b/application/apps/indexer/gui/application/src/session/ui/shared/searching/search.rs @@ -176,7 +176,7 @@ impl SearchState { /// Returns the active operation ID while the search is still running. pub fn processing_search_operation(&self) -> Option { self.search_op.as_ref().and_then(|op| { - if op.phase != OperationPhase::Done { + if op.phase.is_running() { Some(op.id) } else { None @@ -214,7 +214,7 @@ impl SearchState { return; }; - operation.phase = OperationPhase::Done; + operation.phase = OperationPhase::Success; let matches_map = self.matches_map.get_or_insert_default(); @@ -309,7 +309,10 @@ mod tests { state.clear_matches(); assert!(state.processing_search_operation().is_none()); - assert_eq!(state.search_operation_phase(), Some(OperationPhase::Done)); + assert_eq!( + state.search_operation_phase(), + Some(OperationPhase::Success) + ); assert_eq!(state.search_result_count(), 0); assert_eq!(state.indexed_result_count(), 5); assert!(state.current_matches_map().is_none()); diff --git a/application/apps/indexer/gui/application/src/session/ui/shared/searching/search_values.rs b/application/apps/indexer/gui/application/src/session/ui/shared/searching/search_values.rs index e7088319f4..4550f7811d 100644 --- a/application/apps/indexer/gui/application/src/session/ui/shared/searching/search_values.rs +++ b/application/apps/indexer/gui/application/src/session/ui/shared/searching/search_values.rs @@ -51,16 +51,10 @@ impl SearchValuesState { } /// Returns the current operation id while it is still running. - /// - /// `Done` operations are treated as non-running and return `None`. pub fn processing_operation(&self) -> Option { - self.operation.as_ref().and_then(|op| { - if op.phase != OperationPhase::Done { - Some(op.id) - } else { - None - } - }) + self.operation + .as_ref() + .and_then(|op| op.phase.is_running().then_some(op.id)) } pub fn operation_phase(&self) -> Option { @@ -140,10 +134,10 @@ mod tests { let mut state = SearchValuesState::default(); state.set_operation(operation_id); - let outcome = state.update_operation(operation_id, OperationPhase::Done); + let outcome = state.update_operation(operation_id, OperationPhase::Success); assert!(matches!(outcome, UpdateOperationOutcome::Consumed)); assert!(state.processing_operation().is_none()); - assert_eq!(state.operation_phase(), Some(OperationPhase::Done)); + assert_eq!(state.operation_phase(), Some(OperationPhase::Success)); } } diff --git a/application/apps/indexer/gui/application/src/session/ui/status_bar.rs b/application/apps/indexer/gui/application/src/session/ui/status_bar.rs index 44efb69a9d..b0e35761ea 100644 --- a/application/apps/indexer/gui/application/src/session/ui/status_bar.rs +++ b/application/apps/indexer/gui/application/src/session/ui/status_bar.rs @@ -113,8 +113,8 @@ fn observe_states(shared: &SessionShared, ui: &mut Ui) { ui.label(title); ui.horizontal(|ui| { - if let Some(duratio) = operation.total_run_duration() { - let duration_txt = format!("[{:.2}s]", duratio.as_secs_f32()); + if let Some(duration) = operation.run_duration() { + let duration_txt = format!("[{:.2}s]", duration.as_secs_f32()); ui.label(duration_txt); } ui.label(desc); diff --git a/application/apps/indexer/parsers/src/dlt/fmt.rs b/application/apps/indexer/parsers/src/dlt/fmt.rs index 1b7758c670..8c9604d786 100644 --- a/application/apps/indexer/parsers/src/dlt/fmt.rs +++ b/application/apps/indexer/parsers/src/dlt/fmt.rs @@ -38,7 +38,7 @@ use std::{ }; /// Separator to used between the columns in DLT [`FormattableMessage`]. -pub const DLT_COLUMN_SENTINAL: char = '\u{0004}'; +pub const DLT_COLUMN_SENTINAL: &str = crate::COLUMN_SEPARATOR; /// Separator to used between the arguments in the payload of DLT [`FormattableMessage`]. pub const DLT_ARGUMENT_SENTINAL: char = '\u{0005}'; diff --git a/application/apps/indexer/parsers/src/lib.rs b/application/apps/indexer/parsers/src/lib.rs index 279156005c..9a42b6024c 100644 --- a/application/apps/indexer/parsers/src/lib.rs +++ b/application/apps/indexer/parsers/src/lib.rs @@ -2,6 +2,10 @@ pub mod dlt; pub mod someip; pub mod text; + +/// Unified separator used by built-in parsers to delimit rendered table columns. +pub const COLUMN_SEPARATOR: &str = "\u{0004}"; + use serde::Serialize; use std::{ fmt::{Debug, Display}, diff --git a/application/apps/indexer/parsers/src/someip.rs b/application/apps/indexer/parsers/src/someip.rs index 3d832a49fa..6507a9d5c2 100644 --- a/application/apps/indexer/parsers/src/someip.rs +++ b/application/apps/indexer/parsers/src/someip.rs @@ -22,7 +22,7 @@ use regex::Regex; use serde::Serialize; /// Marker for a column separator in the output string. -pub const COLUMN_SEP: char = '\u{0004}'; // EOT +pub const COLUMN_SEP: &str = crate::COLUMN_SEPARATOR; // EOT /// Marker for a newline in the output string. pub const LINE_SEP: &str = "\u{0006}"; // ACK diff --git a/application/apps/indexer/plugins_host/src/parser_shared/mod.rs b/application/apps/indexer/plugins_host/src/parser_shared/mod.rs index d11c82616d..15da801e62 100644 --- a/application/apps/indexer/plugins_host/src/parser_shared/mod.rs +++ b/application/apps/indexer/plugins_host/src/parser_shared/mod.rs @@ -15,7 +15,7 @@ use crate::{ pub mod plugin_parse_message; /// Marker for a column separator in the output string. -pub const COLUMN_SEP: &str = "\u{0004}"; +pub const COLUMN_SEP: &str = parsers::COLUMN_SEPARATOR; /// Uses [`WasmHost`](crate::wasm_host::WasmHost) to communicate with WASM parser plugin. pub struct PluginsParser { diff --git a/application/apps/indexer/session/src/state/mod.rs b/application/apps/indexer/session/src/state/mod.rs index cbc65f285a..d33e794c33 100644 --- a/application/apps/indexer/session/src/state/mod.rs +++ b/application/apps/indexer/session/src/state/mod.rs @@ -46,8 +46,10 @@ pub use indexes::{ }; use observed::Observed; use searchers::{SearchRequest, SearchResponse}; -pub use session_file::{SessionFile, SessionFileOrigin, SessionFileState}; use stypes::{FilterMatch, GrabbedElement}; + +pub use observed::is_raw_export_available_for; +pub use session_file::{SessionFile, SessionFileOrigin, SessionFileState}; pub use values::{Values, ValuesError}; /// Status of session state. diff --git a/application/apps/indexer/session/src/state/observed.rs b/application/apps/indexer/session/src/state/observed.rs index 875e988e74..9696c2f687 100644 --- a/application/apps/indexer/session/src/state/observed.rs +++ b/application/apps/indexer/session/src/state/observed.rs @@ -17,10 +17,7 @@ impl Observed { /// Check any of the executed observe operations supports file (raw) export function. pub fn is_file_based_export_possible(&self) -> bool { - !self.executed.iter().any(|opt| { - matches!(opt.origin, stypes::ObserveOrigin::Stream(..)) - || matches!(opt.parser, stypes::ParserType::Plugin(..)) - }) + is_raw_export_available_for(&self.executed) } /// Get sources of type file form the already executed observe operations. @@ -46,6 +43,15 @@ impl Observed { } } +pub fn is_raw_export_available_for(options: &[stypes::ObserveOptions]) -> bool { + options.iter().all(|opt| { + !matches!( + (&opt.origin, &opt.parser), + (stypes::ObserveOrigin::Stream(..), _) | (_, stypes::ParserType::Plugin(..)) + ) + }) +} + impl Default for Observed { fn default() -> Self { Self::new()