diff --git a/crates/app/src/common/ui/tab_strip.rs b/crates/app/src/common/ui/tab_strip.rs index a0a73bec5f..8fd0f48e11 100644 --- a/crates/app/src/common/ui/tab_strip.rs +++ b/crates/app/src/common/ui/tab_strip.rs @@ -185,12 +185,14 @@ impl TabStrip { /// selection and emitted events. /// - `render_content` renders the visible content for one tab. It receives the tab body `Ui` /// and the tab index from `tabs`. + /// - `render_context_menu` renders tab-specific commands and returns whether it added any. pub fn show( self, ui: &mut Ui, control_rect: Rect, tabs: &[TabSpec<'_>], mut render_content: impl FnMut(&mut Ui, usize), + mut render_context_menu: impl FnMut(&mut Ui, usize) -> bool, ) -> Option { let rail_fill = colors::main_accent_background(ui.visuals().dark_mode); ui.painter().rect_filled(control_rect, 0, rail_fill); @@ -236,11 +238,16 @@ impl TabStrip { for (tab_index, spec) in tabs.iter().enumerate() { let selected = tab_index == selected_tab_index; - if let Some(event) = - render_tab(ui, &self, strip_rect, tab_index, spec, selected, |ui| { - render_content(ui, tab_index) - }) - { + if let Some(event) = render_tab( + ui, + &self, + strip_rect, + tab_index, + spec, + selected, + |ui| render_content(ui, tab_index), + |ui| render_context_menu(ui, tab_index), + ) { record_tab_event(&mut pending_event, event); } } @@ -400,6 +407,7 @@ fn record_tab_event(pending_event: &mut Option, event: TabEvent) { } } +#[allow(clippy::too_many_arguments)] fn render_tab( ui: &mut Ui, strip: &TabStrip, @@ -408,6 +416,7 @@ fn render_tab( spec: &TabSpec<'_>, selected: bool, add_content: impl FnOnce(&mut Ui), + add_context_menu: impl FnOnce(&mut Ui) -> bool, ) -> Option { let TabSpec { key, @@ -431,6 +440,10 @@ fn render_tab( let mut action = None; if can_close { body_response.context_menu(|ui| { + ui.set_min_width(140.0); + if add_context_menu(ui) { + ui.separator(); + } if ui.button("Close").clicked() { action = Some(TabEvent::Close(tab_index)); ui.close(); diff --git a/crates/app/src/host/communication.rs b/crates/app/src/host/communication.rs index 49f4d8c2c1..a42d0880e9 100644 --- a/crates/app/src/host/communication.rs +++ b/crates/app/src/host/communication.rs @@ -66,6 +66,9 @@ impl ServiceSenders { /// Send notification to host and waking up UI. /// + /// Direct service notifications are displayed as banners and retained in notification + /// history. + /// /// # Return /// Returns `true` if the notification is sent successfully. On send errors /// it will log the error and return `false`. diff --git a/crates/app/src/host/notification.rs b/crates/app/src/host/notification.rs index 7599a399b6..a1a7892a81 100644 --- a/crates/app/src/host/notification.rs +++ b/crates/app/src/host/notification.rs @@ -1,5 +1,16 @@ +//! Application notification content and UI display policy. + use crate::{host::error::HostError, session::error::SessionError}; +/// A queued application notification and its UI display policy. +#[derive(Debug)] +pub struct NotificationRequest { + /// Notification content and severity. + pub notification: AppNotification, + /// UI surfaces on which to show the notification. + pub display: NotificationDisplay, +} + #[derive(Debug)] pub enum AppNotification { HostError(HostError), @@ -12,3 +23,10 @@ pub enum AppNotification { /// General info notification. Info(String), } + +/// UI surfaces on which a notification is shown. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NotificationDisplay { + HistoryAndBanner, + BannerOnly, +} diff --git a/crates/app/src/host/ui/actions/mod.rs b/crates/app/src/host/ui/actions/mod.rs index cf1a97c11d..75154d6c4e 100644 --- a/crates/app/src/host/ui/actions/mod.rs +++ b/crates/app/src/host/ui/actions/mod.rs @@ -1,8 +1,9 @@ use std::time::Duration; -use crate::host::notification::AppNotification; use tokio::{runtime::Handle, sync::mpsc}; +use crate::host::notification::{AppNotification, NotificationDisplay, NotificationRequest}; + mod file_dialog; mod host_action; @@ -16,7 +17,7 @@ pub struct UiActions { /// Tokio runtime handle for UI components that need to spawn service-side work. #[expect(dead_code, reason = "Reserved for upcoming UI async actions.")] pub tokio_handle: Handle, - pending_notifications: Vec, + pending_notifications: Vec, pub file_dialog: FileDialogHandle, // Queue of actions for the Host to process next frame host_actions: Vec, @@ -32,11 +33,25 @@ impl UiActions { } } - pub fn add_notification(&mut self, notifi: AppNotification) { - self.pending_notifications.push(notifi); + /// Queues a notification for history and the active banner. + pub fn add_notification(&mut self, notification: AppNotification) { + let request = NotificationRequest { + notification, + display: NotificationDisplay::HistoryAndBanner, + }; + self.pending_notifications.push(request); + } + + /// Queues a banner without adding it to notification history. + pub fn add_transient_notification(&mut self, notification: AppNotification) { + let request = NotificationRequest { + notification, + display: NotificationDisplay::BannerOnly, + }; + self.pending_notifications.push(request); } - pub fn drain_notifications(&mut self) -> impl Iterator { + pub fn drain_notifications(&mut self) -> impl Iterator { self.pending_notifications.drain(..) } diff --git a/crates/app/src/host/ui/mod.rs b/crates/app/src/host/ui/mod.rs index c99d3881ef..4fcc7af057 100644 --- a/crates/app/src/host/ui/mod.rs +++ b/crates/app/src/host/ui/mod.rs @@ -19,6 +19,7 @@ use crate::{ common::{app_style, colors}, communication::{UiReceivers, UiSenders}, message::HostMessage, + notification::NotificationDisplay, service::HostService, ui::{ command_palette::CommandPalette, @@ -420,9 +421,16 @@ impl Host { fn handle_ui_actions(&mut self, ctx: &Context) { let mut changed = false; - for notifi in self.ui_actions.drain_notifications() { + for request in self.ui_actions.drain_notifications() { changed = true; - self.notifications.add(notifi); + match request.display { + NotificationDisplay::HistoryAndBanner => { + self.notifications.add(request.notification) + } + NotificationDisplay::BannerOnly => { + self.notifications.add_transient(request.notification) + } + } } let host_actions: Vec<_> = self.ui_actions.drain_host_actions().collect(); @@ -595,7 +603,7 @@ impl eframe::App for Host { continue; }; - session.handle_messages(&mut self.ui_actions, &mut self.storage, registry); + session.handle_messages(ctx, &mut self.ui_actions, &mut self.storage, registry); } self.handle_recent_sessions(); diff --git a/crates/app/src/host/ui/notification/banner.rs b/crates/app/src/host/ui/notification/banner.rs index 27a19ec35d..0537dd18b9 100644 --- a/crates/app/src/host/ui/notification/banner.rs +++ b/crates/app/src/host/ui/notification/banner.rs @@ -13,6 +13,7 @@ use super::NotificationEntry; #[derive(Debug, Clone)] pub struct NotificationBanner { entry: NotificationEntry, + history_backed: bool, remaining: Duration, created_at: Instant, last_updated: Instant, @@ -22,12 +23,19 @@ pub struct NotificationBanner { } impl NotificationBanner { - pub fn new(entry: NotificationEntry) -> Self { + /// Creates a banner with its full display lifetime available. + /// + /// # Arguments + /// + /// * `entry` - Display-ready notification content. + /// * `history_backed` - Whether dismissing the banner should mark notification history as seen. + pub fn new(entry: NotificationEntry, history_backed: bool) -> Self { const BANNER_TTL: Duration = Duration::from_secs(4); let now = Instant::now(); Self { entry, + history_backed, remaining: BANNER_TTL, created_at: now, last_updated: now, @@ -35,6 +43,21 @@ impl NotificationBanner { } } + /// Returns whether this banner represents a notification retained in history. + pub fn history_backed(&self) -> bool { + self.history_backed + } + + /// Renders the banner below the notification button and advances its display lifetime. + /// + /// # Arguments + /// + /// * `button_rect` - Notification button bounds used to position the banner. + /// * `ui` - Parent UI used for rendering and repaint scheduling. + /// + /// # Return + /// + /// Returns `true` when the banner is clicked. pub fn render(&mut self, button_rect: Rect, ui: &mut Ui) -> bool { const BANNER_MAX_WIDTH: f32 = 340.0; const BANNER_MARGIN: f32 = 8.0; @@ -144,6 +167,7 @@ impl NotificationBanner { false } + /// Returns whether the banner display lifetime has elapsed. pub fn expired(&self) -> bool { self.remaining.is_zero() } diff --git a/crates/app/src/host/ui/notification/mod.rs b/crates/app/src/host/ui/notification/mod.rs index 06006c0a6b..3d7f0dad4e 100644 --- a/crates/app/src/host/ui/notification/mod.rs +++ b/crates/app/src/host/ui/notification/mod.rs @@ -75,11 +75,21 @@ impl NotificationUi { self.unseen_top_level = Some(entry.level); } - let active_banner = NotificationBanner::new(entry.clone()); - self.active_banner = Some(active_banner); + self.show_banner(entry.clone(), true); self.queue.add_item(entry); } + /// Shows a notification as a banner without adding it to history. + pub fn add_transient(&mut self, notification: AppNotification) { + let entry = NotificationEntry::from(notification); + self.show_banner(entry, false); + } + + fn show_banner(&mut self, entry: NotificationEntry, history_backed: bool) { + let banner = NotificationBanner::new(entry, history_backed); + self.active_banner = Some(banner); + } + /// Renders the notification button with its popup, modal message, and latest banner. pub fn render_content(&mut self, ui: &mut Ui) { let popup_open = Popup::is_id_open(ui.ctx(), self.popup_id); @@ -146,14 +156,22 @@ impl NotificationUi { let clicked = banner.render(button_rect, ui); if clicked { - // Dismiss the banner and mark notifications as seen on click. - self.active_banner = None; - self.unseen_top_level = None; + self.dismiss_banner(); } else if banner.expired() { self.active_banner = None; } } + fn dismiss_banner(&mut self) { + let Some(banner) = self.active_banner.take() else { + return; + }; + + if banner.history_backed() { + self.unseen_top_level = None; + } + } + /// Renders the notification history popup content. fn popup_content(&mut self, ui: &mut Ui) { let panel_width = (ui.content_rect().width() - 20.) @@ -301,3 +319,25 @@ impl From for NotificationEntry { Self { level, message } } } + +#[cfg(test)] +mod tests { + use super::{NotificationLevel, NotificationUi}; + use crate::host::notification::AppNotification; + + #[test] + fn transient_dismissal_preserves_unseen_history() { + let mut notifications = NotificationUi::default(); + notifications.add(AppNotification::Warning("Retained".to_owned())); + notifications.add_transient(AppNotification::Error("Transient".to_owned())); + + notifications.dismiss_banner(); + + assert_eq!(notifications.queue.len(), 1); + assert_eq!( + notifications.unseen_top_level, + Some(NotificationLevel::Warning) + ); + assert!(notifications.active_banner.is_none()); + } +} diff --git a/crates/app/src/host/ui/shortcuts/handler.rs b/crates/app/src/host/ui/shortcuts/handler.rs index 98c5fe3224..95e7130edf 100644 --- a/crates/app/src/host/ui/shortcuts/handler.rs +++ b/crates/app/src/host/ui/shortcuts/handler.rs @@ -1,4 +1,4 @@ -//! Dispatches host-level shortcuts before forwarding unconsumed shortcuts to the active session. +//! Dispatches host-level input shortcuts to the active application or session scope. use egui::{Context, Event}; @@ -22,6 +22,10 @@ pub fn handle(host: &mut Host, ctx: &Context) -> bool { return false; } + if handle_copy_event(host, ctx) { + return true; + } + if !has_key_press(ctx) { return false; } @@ -46,6 +50,35 @@ pub fn handle(host: &mut Host, ctx: &Context) -> bool { false } +/// Routes a native copy event to the active session without consuming it preemptively. +fn handle_copy_event(host: &mut Host, ctx: &Context) -> bool { + let has_copy_event = ctx.input(|input| { + input + .events + .iter() + .any(|event| matches!(event, Event::Copy)) + }); + if !has_copy_event { + return false; + } + + let Host { + state, + tabs, + ui_actions, + .. + } = host; + + match tabs.active_mut() { + HostTab::Session(session) => session.handle_copy_event(ui_actions, &state.preferences, ctx), + HostTab::Home(_) + | HostTab::SessionSetup(_) + | HostTab::MultiFileSetup(_) + | HostTab::PluginManager(_) + | HostTab::AppSettings(_) => false, + } +} + /// Returns true when a key press event exists in the current frame. fn has_key_press(ctx: &Context) -> bool { ctx.input(|input| { diff --git a/crates/app/src/host/ui/storage/mod.rs b/crates/app/src/host/ui/storage/mod.rs index 0b561fe7eb..93181ec990 100644 --- a/crates/app/src/host/ui/storage/mod.rs +++ b/crates/app/src/host/ui/storage/mod.rs @@ -461,7 +461,10 @@ mod tests { assert!(storage.file_explorer.get_save_data().is_some()); assert!(storage.recent_sessions.get_save_data().is_some()); assert!(matches!( - ui_actions.drain_notifications().next(), + ui_actions + .drain_notifications() + .next() + .map(|request| request.notification), Some(AppNotification::Error(message)) if message.contains("disk full") )); } @@ -566,7 +569,10 @@ mod tests { ); assert!(matches!( - ui_actions.drain_notifications().next(), + ui_actions + .drain_notifications() + .next() + .map(|request| request.notification), Some(AppNotification::Warning(_)) )); } diff --git a/crates/app/src/host/ui/tabs/mod.rs b/crates/app/src/host/ui/tabs/mod.rs index 80e781f4f0..275404e8a7 100644 --- a/crates/app/src/host/ui/tabs/mod.rs +++ b/crates/app/src/host/ui/tabs/mod.rs @@ -460,6 +460,18 @@ impl HostTabs { } impl HostTab { + /// Renders commands owned by this specific tab. + fn render_tab_context_menu(&self, actions: &mut UiActions, ui: &mut egui::Ui) -> bool { + match self { + Self::Session(session) => session.render_tab_context_menu(actions, ui), + Self::Home(_) + | Self::SessionSetup(_) + | Self::MultiFileSetup(_) + | Self::PluginManager(_) + | Self::AppSettings(_) => false, + } + } + /// Returns whether this tab owns an open lightweight input overlay. fn has_input_overlay(&self) -> bool { match self { diff --git a/crates/app/src/host/ui/tabs/render.rs b/crates/app/src/host/ui/tabs/render.rs index 2db831e85d..2663d3880f 100644 --- a/crates/app/src/host/ui/tabs/render.rs +++ b/crates/app/src/host/ui/tabs/render.rs @@ -92,32 +92,42 @@ impl HostTabs { } })); - let tabs_event = strip.show(ui, control_rect, &self.tab_specs, |ui, idx| { - let Some(tab) = self.tabs.get(idx) else { - return; - }; + let tabs = &self.tabs; + let tabs_event = strip.show( + ui, + control_rect, + &self.tab_specs, + |ui, idx| { + let Some(tab) = tabs.get(idx) else { + return; + }; - match tab { - HostTab::Home(_) => { - ui.label( - RichText::new(icons::fill::HOUSE) - .family(phosphor::fill_font_family()) - .size(HOST_TAB_HOME_ICON_SIZE), - ); - } - HostTab::Session(session) => { - let title = session.get_info().title.as_str(); - tab_label(ui, title); + match tab { + HostTab::Home(_) => { + ui.label( + RichText::new(icons::fill::HOUSE) + .family(phosphor::fill_font_family()) + .size(HOST_TAB_HOME_ICON_SIZE), + ); + } + HostTab::Session(session) => { + let title = session.get_info().title.as_str(); + tab_label(ui, title); + } + HostTab::SessionSetup(setup) => { + let title = setup.title(); + tab_label(ui, title.as_ref()); + } + HostTab::MultiFileSetup(_) => tab_label(ui, "Multiple Files"), + HostTab::PluginManager(_) => tab_label(ui, "Plugin Manager"), + HostTab::AppSettings(_) => tab_label(ui, "App Settings"), } - HostTab::SessionSetup(setup) => { - let title = setup.title(); - tab_label(ui, title.as_ref()); - } - HostTab::MultiFileSetup(_) => tab_label(ui, "Multiple Files"), - HostTab::PluginManager(_) => tab_label(ui, "Plugin Manager"), - HostTab::AppSettings(_) => tab_label(ui, "App Settings"), - } - }); + }, + |ui, idx| { + tabs.get(idx) + .is_some_and(|tab| tab.render_tab_context_menu(actions, ui)) + }, + ); if let Some(event) = tabs_event { match event { diff --git a/crates/app/src/session/command.rs b/crates/app/src/session/command.rs index b548f2710f..ce51182790 100644 --- a/crates/app/src/session/command.rs +++ b/crates/app/src/session/command.rs @@ -62,6 +62,9 @@ pub enum SessionCommand { /// Request details for a specific log line. GetSelectedLog(u64), + /// Load the selected stream rows for clipboard copying. + CopyRows(Vec), + /// Request preview content for one attachment. PreviewAttachment(attachment::PreviewRequest), diff --git a/crates/app/src/session/communication.rs b/crates/app/src/session/communication.rs index b5159def3f..bd55953110 100644 --- a/crates/app/src/session/communication.rs +++ b/crates/app/src/session/communication.rs @@ -94,6 +94,9 @@ impl ServiceSenders { /// Send notification to host and waking up UI. /// + /// Direct service notifications are displayed as banners and retained in notification + /// history. + /// /// # Return /// Returns `true` if the notification is sent successfully. On send errors /// it will log the error and return `false`. diff --git a/crates/app/src/session/message.rs b/crates/app/src/session/message.rs index 2c1507dd59..a5601623a4 100644 --- a/crates/app/src/session/message.rs +++ b/crates/app/src/session/message.rs @@ -21,6 +21,9 @@ pub enum SessionMessage { /// Result from fetching a specific log line. SelectedLog(Result), + /// Result from loading selected stream rows for clipboard copying. + CopyRowsLoaded(Result, SessionError>), + // --- Search --- // /// Total number of rows matched by the active search. diff --git a/crates/app/src/session/service/export.rs b/crates/app/src/session/service/export.rs index 11e1e78c8f..20a52f815a 100644 --- a/crates/app/src/session/service/export.rs +++ b/crates/app/src/session/service/export.rs @@ -412,11 +412,11 @@ fn init_session_error_to_session_error(error: InitSessionError) -> SessionError } } -/// Converts selected stream row positions into compact inclusive ranges for raw export. +/// Converts selected stream row positions into compact inclusive ranges. /// -/// 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> { +/// The UI snapshots selection from a hash set, so the service normalizes row order, +/// removes duplicates, and compacts adjacent rows here. +pub(super) fn rows_to_ranges(mut rows: Vec) -> Vec> { rows.sort_unstable(); rows.dedup(); diff --git a/crates/app/src/session/service/mod.rs b/crates/app/src/session/service/mod.rs index 22d94c3780..00584436d1 100644 --- a/crates/app/src/session/service/mod.rs +++ b/crates/app/src/session/service/mod.rs @@ -51,7 +51,7 @@ use crate::{ message::{BookmarkUpdate, SessionMessage}, types::{ ObserveOperation, OperationPhase, - attachment::{PreviewContent, PreviewKind, PreviewRequest}, + attachment::{PreviewContent, PreviewImage, PreviewKind, PreviewRequest}, }, ui::{SessionInfo, chart::ChartBar, definitions::schema::LogSchemaSpec}, }, @@ -450,6 +450,25 @@ impl SessionService { .send_session_msg(SessionMessage::SelectedLog(selected_log)) .await; } + SessionCommand::CopyRows(rows) => { + let ranges = export::rows_to_ranges(rows); + let result = if ranges.is_empty() { + Ok(Vec::new()) + } else { + self.session + .grab_ranges(ranges) + .await + .map(|elements| elements.0) + .map_err(SessionError::from) + }; + + // Keep display-specific formatting in the UI, which owns the log schema and + // ANSI display policy. Manual testing with 10,000 rows remained responsive, + // so moving formatting off the UI thread does not currently justify duplicating + // schema state or adding worker handoff complexity. + let msg = SessionMessage::CopyRowsLoaded(result); + self.senders.send_session_msg(msg).await; + } SessionCommand::PreviewAttachment(request) => { self.preview_attachment(request).await; } @@ -742,12 +761,14 @@ impl SessionService { .await .map_err(|error| ComputationError::Decoding(error.to_string()))? .map_err(|error| ComputationError::Decoding(error.to_string()))?; + let pixels = Arc::new(color_image); let texture = self.senders.egui_ctx().load_texture( format!("attachment-preview-{attachment_id}"), - color_image, + Arc::clone(&pixels), egui::TextureOptions::LINEAR, ); - Ok(PreviewContent::Image(texture)) + let image = PreviewImage::new(pixels, texture); + Ok(PreviewContent::Image(image)) } PreviewKind::Unsupported => Err(ComputationError::OperationNotSupported( "attachment preview".to_string(), diff --git a/crates/app/src/session/types/attachment.rs b/crates/app/src/session/types/attachment.rs index 49bcc91d4d..bec7c90b33 100644 --- a/crates/app/src/session/types/attachment.rs +++ b/crates/app/src/session/types/attachment.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::{path::PathBuf, sync::Arc}; use uuid::Uuid; @@ -18,8 +18,37 @@ pub enum PreviewKind { pub enum PreviewContent { /// Text content decoded from a UTF-8 file. Text(String), - /// Image content uploaded to egui's texture manager. - Image(egui::TextureHandle), + /// Decoded image content and its uploaded rendering texture. + Image(PreviewImage), +} + +/// Shared image data retained for rendering and clipboard submission. +#[derive(Clone)] +pub struct PreviewImage { + pixels: Arc, + texture: egui::TextureHandle, +} + +impl PreviewContent { + /// Submits the complete preview content to the system clipboard. + pub fn copy_to(&self, ctx: &egui::Context) { + match self { + Self::Text(text) => ctx.copy_text(text.clone()), + Self::Image(image) => ctx.copy_image(image.pixels.as_ref().clone()), + } + } +} + +impl PreviewImage { + /// Retains decoded pixels alongside their rendering texture. + pub fn new(pixels: Arc, texture: egui::TextureHandle) -> Self { + Self { pixels, texture } + } + + /// Returns the texture used to render the preview. + pub fn texture(&self) -> &egui::TextureHandle { + &self.texture + } } /// Request sent from UI to service to load and convert one attachment preview. @@ -83,9 +112,9 @@ impl std::fmt::Debug for PreviewContent { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Text(_) => f.debug_tuple("Text").field(&"...").finish(), - Self::Image(texture) => f + Self::Image(image) => f .debug_struct("Image") - .field("size", &texture.size()) + .field("size", &image.texture.size()) .finish(), } } diff --git a/crates/app/src/session/ui/attachment_modal.rs b/crates/app/src/session/ui/attachment_modal.rs index 496c13989f..44f46924ef 100644 --- a/crates/app/src/session/ui/attachment_modal.rs +++ b/crates/app/src/session/ui/attachment_modal.rs @@ -11,9 +11,13 @@ use crate::{ phosphor::icons, ui::modal::{ModalSize, ResponsiveModalSize, show_modal}, }, + host::ui::UiActions, session::{ types::attachment::PreviewContent, - ui::shared::{AttachmentModalState, AttachmentsState}, + ui::{ + common::attachment_preview::render_copy_button, + shared::{AttachmentModalState, AttachmentsState}, + }, }, }; @@ -40,7 +44,12 @@ impl AttachmentModalUi { Self::default() } - pub fn render_content(&mut self, attachments: &mut AttachmentsState, ui: &Ui) { + pub fn render_content( + &mut self, + attachments: &mut AttachmentsState, + actions: &mut UiActions, + ui: &Ui, + ) { if attachments.preview_modal().closed() { return; } @@ -64,7 +73,7 @@ impl AttachmentModalUi { modal_size.x, (modal_size.y - HEADER_HEIGHT - HEADER_SPACING).max(0.0), ); - self.render_body(attachments.preview_modal(), body_size, ui); + self.render_body(attachments.preview_modal(), actions, body_size, ui); }, ); @@ -117,7 +126,13 @@ impl AttachmentModalUi { close } - fn render_body(&mut self, state: &AttachmentModalState, size: egui::Vec2, ui: &mut Ui) { + fn render_body( + &mut self, + state: &AttachmentModalState, + actions: &mut UiActions, + size: egui::Vec2, + ui: &mut Ui, + ) { match state { AttachmentModalState::Closed => {} AttachmentModalState::Pending { .. } => render_centered_status(size, ui, |ui| { @@ -126,7 +141,7 @@ impl AttachmentModalUi { AttachmentModalState::Content { attachment, content, - } => self.render_preview_content(attachment, content, size, ui), + } => self.render_preview_content(attachment, content, actions, size, ui), AttachmentModalState::NotSupported { .. } => render_centered_status(size, ui, |ui| { ui.label(RichText::new("Preview unavailable for this attachment type.").weak()); }), @@ -137,18 +152,20 @@ impl AttachmentModalUi { &mut self, attachment: &AttachmentInfo, content: &PreviewContent, + actions: &mut UiActions, size: egui::Vec2, ui: &mut Ui, ) { match content { - PreviewContent::Text(content) => { - render_content_frame(size, ui, |ui, inner_size| { - render_text(content, attachment.uuid, inner_size, ui); + PreviewContent::Text(text) => { + let frame_rect = render_content_frame(size, ui, |ui, inner_size| { + render_text(text, attachment.uuid, inner_size, ui); }); + render_copy_button(ui, frame_rect, content, actions); } - PreviewContent::Image(texture) => { + PreviewContent::Image(image) => { let frame_rect = render_content_frame(size, ui, |ui, inner_size| { - self.render_image(texture, inner_size, ui) + self.render_image(image.texture(), inner_size, ui) }); let (counter_clicked, clockwise_clicked) = render_image_rotation_buttons(ui, frame_rect); @@ -158,6 +175,7 @@ impl AttachmentModalUi { if clockwise_clicked { self.rotate_clockwise(); } + render_copy_button(ui, frame_rect, content, actions); } } } diff --git a/crates/app/src/session/ui/bottom_panel/details/mod.rs b/crates/app/src/session/ui/bottom_panel/details/mod.rs index d0c2f4b449..4dc5ea20d0 100644 --- a/crates/app/src/session/ui/bottom_panel/details/mod.rs +++ b/crates/app/src/session/ui/bottom_panel/details/mod.rs @@ -1,17 +1,29 @@ -use egui::{Frame, Label, Margin, RichText, Ui, Widget}; +use egui::{Align, Frame, Label, Layout, Margin, RichText, Ui, Widget}; use memchr::memchr; use stypes::GrabbedElement; - -use crate::session::ui::{ - common::{ - ansi_text::{AnsiText, parse_ansi_text}, - log_table::text::ansi_layout_job, +use tokio::sync::mpsc::Sender; + +use crate::{ + common::{phosphor::icons, ui::buttons}, + host::ui::UiActions, + session::{ + command::SessionCommand, + ui::{ + common::{ + ansi_text::{AnsiText, parse_ansi_text}, + log_table::{ + copy::{CopyScope, copy_selected_rows}, + text::ansi_layout_job, + }, + }, + shared::SessionShared, + }, }, - shared::SessionShared, }; -#[derive(Debug, Default)] +#[derive(Debug)] pub struct DetailsUI { + cmd_tx: Sender, loaded_log: Option, } @@ -33,40 +45,62 @@ impl LoadedDetailsLog { } impl DetailsUI { + pub fn new(cmd_tx: Sender) -> Self { + Self { + cmd_tx, + loaded_log: None, + } + } + pub fn handle_selected_log(&mut self, selected_row: Option, log: GrabbedElement) { if selected_row.is_some_and(|row| row == log.pos as u64) { self.loaded_log = Some(LoadedDetailsLog::new(log)); } } - pub fn render_content(&mut self, shared: &SessionShared, ui: &mut Ui) { - if shared.logs.selected_count() != 1 { - return; - } - + pub fn render_content(&mut self, shared: &SessionShared, actions: &mut UiActions, ui: &mut Ui) { let Some(selected_row) = shared.logs.single_selected_row() else { return; }; Frame::NONE.inner_margin(Margin::same(4)).show(ui, |ui| { - let Some(log) = self + let log = self .loaded_log .as_ref() - .filter(|log| log.element.pos == selected_row as usize) - else { - ui.add_space(10.); - Label::new("Loading...").selectable(true).ui(ui); + .filter(|log| log.element.pos == selected_row as usize); + + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.add_space(10.); + match log { + Some(log) => { + Label::new(format!("Row #: {}", log.element.pos)) + .selectable(true) + .ui(ui); + } + None => { + Label::new("Loading...").selectable(true).ui(ui); + } + } + }); + + ui.with_layout(Layout::right_to_left(Align::TOP), |ui| { + if buttons::bottom_panel_icon(RichText::new(icons::regular::COPY).size(16.0)) + .ui(ui) + .on_hover_text("Copy selected row") + .clicked() + { + copy_selected_rows(shared, CopyScope::AllSelected, actions, &self.cmd_tx); + } + }); + }); + + let Some(log) = log else { return; }; ui.add_space(10.); - Label::new(format!("Row #: {}", log.element.pos)) - .selectable(true) - .ui(ui); - - ui.add_space(10.); - match &log.ansi_text { Some(ansi_text) => { let content = ansi_layout_job(ui, ansi_text, ui.visuals().strong_text_color()); @@ -84,6 +118,7 @@ impl DetailsUI { #[cfg(test)] mod tests { use stypes::GrabbedElement; + use tokio::sync::mpsc; use super::DetailsUI; @@ -96,9 +131,14 @@ mod tests { } } + fn details() -> DetailsUI { + let (cmd_tx, _) = mpsc::channel(1); + DetailsUI::new(cmd_tx) + } + #[test] fn accepts_only_current_single_selection() { - let mut details = DetailsUI::default(); + let mut details = details(); details.handle_selected_log(Some(4), log(4)); @@ -110,7 +150,7 @@ mod tests { #[test] fn ignores_stale_selected_log_response() { - let mut details = DetailsUI::default(); + let mut details = details(); details.handle_selected_log(Some(4), log(4)); details.handle_selected_log(Some(7), log(4)); diff --git a/crates/app/src/session/ui/bottom_panel/mod.rs b/crates/app/src/session/ui/bottom_panel/mod.rs index 3bfbb4f0bf..33d541a3ad 100644 --- a/crates/app/src/session/ui/bottom_panel/mod.rs +++ b/crates/app/src/session/ui/bottom_panel/mod.rs @@ -51,7 +51,7 @@ impl BottomPanelUI { ) -> Self { Self { search: SearchUI::new(cmd_tx.clone(), schema), - details: DetailsUI::default(), + details: DetailsUI::new(cmd_tx.clone()), library: LibraryUI::new(cmd_tx.clone()), presets: PresetsUI::new(cmd_tx.clone(), host_cmd_tx), chart: ChartUI::new(cmd_tx), @@ -72,7 +72,7 @@ impl BottomPanelUI { self.search .render_content(shared, actions, &mut registry.filters, ui) } - BottomTabType::Details => self.details.render_content(shared, ui), + BottomTabType::Details => self.details.render_content(shared, actions, ui), BottomTabType::Library => { self.library .render_content(shared, actions, &mut registry.filters, ui) @@ -123,15 +123,21 @@ impl BottomPanelUI { .tooltip(tab.label()) }); - match strip.show(ui, control_rect, &tabs, |ui, idx| { - let label = bottom_tab_from_index(idx).label(); - ui.add( - Label::new(RichText::new(label).text_style(TextStyle::Button)) - .truncate() - .show_tooltip_when_elided(false) - .halign(Align::Center), - ); - }) { + match strip.show( + ui, + control_rect, + &tabs, + |ui, idx| { + let label = bottom_tab_from_index(idx).label(); + ui.add( + Label::new(RichText::new(label).text_style(TextStyle::Button)) + .truncate() + .show_tooltip_when_elided(false) + .halign(Align::Center), + ); + }, + |_, _| false, + ) { Some(TabEvent::Select(idx)) => shared.bottom_tab = bottom_tab_from_index(idx), Some(TabEvent::Close(_)) | None => {} } diff --git a/crates/app/src/session/ui/bottom_panel/search/nested_search.rs b/crates/app/src/session/ui/bottom_panel/search/nested_search.rs index ffb8dfccfc..fd0d9ce475 100644 --- a/crates/app/src/session/ui/bottom_panel/search/nested_search.rs +++ b/crates/app/src/session/ui/bottom_panel/search/nested_search.rs @@ -300,6 +300,8 @@ fn paint_pending_border(ui: &Ui, rect: Rect) { #[cfg(test)] mod tests { + use std::assert_matches; + use egui::{Context, Event, Id, Key, Modifiers, RawInput, Rect, TextEdit, pos2, vec2}; use processor::search::filter::SearchFilter; use regex::Regex; @@ -479,6 +481,6 @@ mod tests { assert!(cmd_rx.try_recv().is_err()); let notifications: Vec<_> = actions.drain_notifications().collect(); assert_eq!(notifications.len(), 1); - assert!(matches!(notifications[0], AppNotification::Warning(_))); + assert_matches!(notifications[0].notification, AppNotification::Warning(_)); } } diff --git a/crates/app/src/session/ui/bottom_panel/search/search_bar.rs b/crates/app/src/session/ui/bottom_panel/search/search_bar.rs index e1bb5d062b..5dc1f88e7a 100644 --- a/crates/app/src/session/ui/bottom_panel/search/search_bar.rs +++ b/crates/app/src/session/ui/bottom_panel/search/search_bar.rs @@ -377,7 +377,7 @@ impl SearchBar { #[cfg(test)] mod tests { - use std::path::PathBuf; + use std::{assert_matches, path::PathBuf}; use stypes::{FileFormat, ObserveOrigin}; use tokio::{runtime::Runtime, sync::mpsc}; @@ -439,10 +439,10 @@ mod tests { let notifications: Vec<_> = actions.drain_notifications().collect(); assert_eq!(notifications.len(), 1); - assert!(matches!( - ¬ifications[0], + assert_matches!( + ¬ifications[0].notification, AppNotification::Warning(msg) if msg.starts_with("Filter couldn't be applied: Invalid regex:") - )); + ); } } diff --git a/crates/app/src/session/ui/bottom_panel/search/search_table.rs b/crates/app/src/session/ui/bottom_panel/search/search_table.rs index 59d860f17d..791df76957 100644 --- a/crates/app/src/session/ui/bottom_panel/search/search_table.rs +++ b/crates/app/src/session/ui/bottom_panel/search/search_table.rs @@ -22,6 +22,7 @@ use crate::{ self, log_table::{ LogTableKind, + copy::{self, CopyScope}, table::{ TableScroll, activate_table_on_click, apply_columns_to_table_state, grab_cmd_consts, render_active_table_indicator, render_row_header, @@ -166,13 +167,14 @@ impl SearchTable { registry: &FilterRegistry, ui: &mut Ui, ) { + copy::render_copy_action(shared, CopyScope::SearchRows, actions, &self.cmd_tx, 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(); - let selected_target = ExportTarget::Rows(shared.logs.selected_rows()); + let selected_target = ExportTarget::Rows(shared.logs.selected_rows().collect()); let selected_label = export::rendered_text_export_label(shared.schema.as_ref(), &selected_target); if ui @@ -207,7 +209,7 @@ impl SearchTable { ) .clicked() { - let target = ExportTarget::Rows(shared.logs.selected_rows()); + let target = ExportTarget::Rows(shared.logs.selected_rows().collect()); let file_name = export::default_raw_file_name(shared); shared.exports.open_raw_dialog( actions, diff --git a/crates/app/src/session/ui/common/attachment_preview.rs b/crates/app/src/session/ui/common/attachment_preview.rs new file mode 100644 index 0000000000..5da8f96a5f --- /dev/null +++ b/crates/app/src/session/ui/common/attachment_preview.rs @@ -0,0 +1,56 @@ +//! Shared controls rendered over attachment preview frames. + +use egui::{Button, Rect, RichText, Ui, pos2, vec2}; + +use crate::{ + common::phosphor::icons, + host::{notification::AppNotification, ui::UiActions}, + session::types::attachment::PreviewContent, +}; + +/// Renders the clipboard action over the top-right corner of an attachment preview. +/// +/// Returns `true` when the copy button was clicked. +pub fn render_copy_button( + ui: &mut Ui, + frame_rect: Rect, + content: &PreviewContent, + actions: &mut UiActions, +) -> bool { + const BUTTON_SIZE: f32 = 26.0; + const ICON_SIZE: f32 = 16.0; + const FRAME_INSET: f32 = 8.0; + + let button_rect = Rect::from_min_size( + pos2( + frame_rect.right() - FRAME_INSET - BUTTON_SIZE, + frame_rect.top() + FRAME_INSET, + ), + vec2(BUTTON_SIZE, BUTTON_SIZE), + ); + let tooltip = match content { + PreviewContent::Text(_) => "Copy attachment text", + PreviewContent::Image(_) => "Copy image", + }; + let clicked = ui + .put( + button_rect, + Button::new(RichText::new(icons::regular::COPY).size(ICON_SIZE)) + .frame(true) + .frame_when_inactive(false), + ) + .on_hover_text(tooltip) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked(); + + if clicked { + content.copy_to(ui.ctx()); + let message = match content { + PreviewContent::Text(_) => "Copied attachment text to clipboard.", + PreviewContent::Image(_) => "Copied image to clipboard.", + }; + actions.add_transient_notification(AppNotification::Info(message.to_owned())); + } + + clicked +} diff --git a/crates/app/src/session/ui/common/log_table/copy.rs b/crates/app/src/session/ui/common/log_table/copy.rs new file mode 100644 index 0000000000..a87c4cc5e0 --- /dev/null +++ b/crates/app/src/session/ui/common/log_table/copy.rs @@ -0,0 +1,262 @@ +use std::fmt::Write as _; + +use egui::{Button, Key, KeyboardShortcut, Modifiers, Ui}; +use stypes::GrabbedElement; +use tokio::sync::mpsc::Sender; + +use crate::{ + host::ui::UiActions, + session::{ + command::SessionCommand, + ui::{ + definitions::{LogTableCell, schema::LogSchema}, + shared::SessionShared, + }, + }, +}; + +const COPY_COLUMN_SEPARATOR: &str = " | "; + +/// Platform copy command reserved for selected log rows. +pub const COPY_ROWS_SHORTCUT: KeyboardShortcut = + KeyboardShortcut::new(Modifiers::COMMAND.plus(Modifiers::SHIFT), Key::C); + +/// Controls which globally selected rows a table copies. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CopyScope { + /// Copy every selected stream row. + AllSelected, + /// Copy selected rows that belong to the search table's logical contents. + SearchRows, +} + +/// Renders the selected-row clipboard action shared by both log tables. +pub fn render_copy_action( + shared: &SessionShared, + scope: CopyScope, + actions: &mut UiActions, + cmd_tx: &Sender, + ui: &mut Ui, +) { + let selected_count = selected_rows(shared, scope).count(); + let label = match selected_count { + 0 => String::from("Copy Selected Rows"), + 1 => String::from("Copy 1 Row"), + count => format!("Copy {count} Rows"), + }; + + let shortcut_text = ui.ctx().format_shortcut(©_ROWS_SHORTCUT); + let button = Button::new(label).shortcut_text(shortcut_text); + if ui.add_enabled(selected_count > 0, button).clicked() { + copy_selected_rows(shared, scope, actions, cmd_tx); + ui.close(); + } + + ui.separator(); +} + +/// Requests a copy for selected rows in the supplied table scope. +pub fn copy_selected_rows( + shared: &SessionShared, + scope: CopyScope, + actions: &mut UiActions, + cmd_tx: &Sender, +) -> bool { + let rows = selected_rows(shared, scope).collect::>(); + if rows.is_empty() { + return false; + } + + actions.try_send_command(cmd_tx, SessionCommand::CopyRows(rows)); + true +} + +fn selected_rows(shared: &SessionShared, scope: CopyScope) -> impl Iterator + '_ { + shared.logs.selected_rows().filter(move |&row| match scope { + CopyScope::AllSelected => true, + CopyScope::SearchRows => shared.search.has_match(row) || shared.logs.is_bookmarked(row), + }) +} + +/// Formats loaded stream rows as readable clipboard text. +pub fn format_rows(mut rows: Vec, schema: &dyn LogSchema) -> String { + rows.sort_unstable_by_key(|element| element.pos); + + let mut output = String::new(); + for (row_index, mut element) in rows.into_iter().enumerate() { + if row_index > 0 { + output.push('\n'); + } + write!(output, "{}", element.pos).expect("writing to a String cannot fail"); + + let mut ranges = schema.prepare_log(&mut element).into_iter(); + for _ in schema.columns() { + output.push_str(COPY_COLUMN_SEPARATOR); + + let Some(range) = ranges.next() else { + continue; + }; + match LogTableCell::from_range(&element.content, range) { + LogTableCell::Plain(range) => { + let text = element.content.get(range).unwrap_or_default(); + append_field(&mut output, text); + } + LogTableCell::Ansi(ansi_text) => append_field(&mut output, &ansi_text.text), + } + } + } + + output +} + +fn append_field(output: &mut String, field: &str) { + for character in field.chars() { + match character { + '\r' | '\n' => output.push(' '), + _ => output.push(character), + } + } +} + +#[cfg(test)] +mod tests { + use std::{ops::Range, path::PathBuf}; + + use egui_table::Column; + use stypes::{FileFormat, FilterMatch, GrabbedElement, ObserveOrigin}; + use uuid::Uuid; + + use super::{CopyScope, format_rows, selected_rows}; + use crate::{ + host::common::parsers::ParserNames, + session::{ + types::ObserveOperation, + ui::{ + SessionInfo, + definitions::schema::{ + ColumnInfo, LogSchema, LogSchemaSpec, map_columns_with_separator, + text::TextLogSchema, + }, + shared::SessionShared, + }, + }, + }; + + #[derive(Debug)] + struct StructuredSchema { + columns: [ColumnInfo; 3], + } + + impl Default for StructuredSchema { + fn default() -> Self { + Self { + columns: [ + ColumnInfo::new("Time", "Time", Column::default()), + ColumnInfo::new("Level", "Level", Column::default()), + ColumnInfo::new("Message", "Message", Column::default()), + ], + } + } + } + + impl LogSchema for StructuredSchema { + fn has_headers(&self) -> bool { + true + } + + fn columns(&self) -> &[ColumnInfo] { + &self.columns + } + + fn prepare_log(&self, element: &mut GrabbedElement) -> Vec> { + let mut ranges = Vec::new(); + map_columns_with_separator(&element.content, &mut ranges, "|"); + ranges + } + } + + fn element(pos: usize, content: &str) -> GrabbedElement { + GrabbedElement { + source_id: 0, + content: content.to_owned(), + pos, + nature: 0, + } + } + + fn shared() -> SessionShared { + let session_id = Uuid::new_v4(); + let origin = ObserveOrigin::File( + "source".to_owned(), + FileFormat::Text, + PathBuf::from("source.log"), + ); + let observe_op = ObserveOperation::new(Uuid::new_v4(), origin); + let session_info = SessionInfo { + id: session_id, + title: "test".to_owned(), + parser: ParserNames::Text, + raw_export_supported: false, + }; + + SessionShared::new(session_info, observe_op, LogSchemaSpec::Text) + } + + #[test] + fn search_scope_includes_matches_and_bookmarks_only() { + let mut shared = shared(); + shared.logs.replace_selection_with_rows(&[10, 20, 30, 40]); + shared.insert_bookmark(20); + shared.insert_bookmark(30); + shared.search.set_search_operation(Uuid::new_v4()); + shared.search.append_matches(vec![ + FilterMatch { + index: 10, + filters: vec![0], + }, + FilterMatch { + index: 30, + filters: vec![0], + }, + ]); + + let mut rows = selected_rows(&shared, CopyScope::SearchRows).collect::>(); + rows.sort_unstable(); + + assert_eq!(rows, vec![10, 20, 30]); + } + + #[test] + fn plain_rows_are_sorted_and_prefixed_without_trailing_newline() { + let rows = vec![element(42, "later"), element(3, "first")]; + + let text = format_rows(rows, &TextLogSchema::default()); + + assert_eq!(text, "3 | first\n42 | later"); + assert!(!text.ends_with('\n')); + } + + #[test] + fn structured_rows_include_defined_columns_without_headers_or_extra_fields() { + let rows = vec![element(7, "12:00||started|unexpected")]; + + let text = format_rows(rows, &StructuredSchema::default()); + + assert_eq!(text, "7 | 12:00 | | started"); + assert!(!text.contains("Time")); + assert!(!text.contains("Level")); + assert!(!text.contains("Message")); + assert!(!text.contains("unexpected")); + } + + #[test] + fn ansi_is_stripped_and_embedded_line_breaks_become_spaces() { + let rows = vec![element(9, "before\r\x1b[31mred\x1b[0m\nafter")]; + + let text = format_rows(rows, &TextLogSchema::default()); + + assert_eq!(text, "9 | before red after"); + assert!(!text.contains('\x1b')); + assert_eq!(text.lines().count(), 1); + } +} diff --git a/crates/app/src/session/ui/common/log_table/mod.rs b/crates/app/src/session/ui/common/log_table/mod.rs index 470625c422..ac9bcc790d 100644 --- a/crates/app/src/session/ui/common/log_table/mod.rs +++ b/crates/app/src/session/ui/common/log_table/mod.rs @@ -14,5 +14,6 @@ pub enum LogTableKind { Search, } +pub mod copy; pub mod table; pub mod text; diff --git a/crates/app/src/session/ui/common/log_table/text.rs b/crates/app/src/session/ui/common/log_table/text.rs index ec17c9748c..45dadb3d40 100644 --- a/crates/app/src/session/ui/common/log_table/text.rs +++ b/crates/app/src/session/ui/common/log_table/text.rs @@ -33,18 +33,32 @@ pub fn render_log_cell_text( col_idx: usize, shared: &SessionShared, ) -> Response { - let Some(cell) = item.cells.get(col_idx) else { - return ui.monospace(""); - }; - - let main_log_pos = item.element.pos as u64; - match cell { - LogTableCell::Plain(range) => { + let response = match item.cells.get(col_idx) { + Some(LogTableCell::Plain(range)) => { let content = item.element.content.get(range.clone()).unwrap_or_default(); - render_plain_cell(ui, content, main_log_pos, shared) + render_plain_cell(ui, content, item.element.pos as u64, shared) } - LogTableCell::Ansi(ansi_text) => render_ansi_cell(ui, ansi_text, main_log_pos, shared), - } + Some(LogTableCell::Ansi(ansi_text)) => { + render_ansi_cell(ui, ansi_text, item.element.pos as u64, shared) + } + None => ui.monospace(""), + }; + + // Improve logs manual selection by expanding text respond, which: + // - Make dragging from everywhere selects texts. + // - Avoid egui_table drag on select annoying behavior. + expand_cell_response(ui, response) +} + +fn expand_cell_response(ui: &Ui, mut response: Response) -> Response { + // Selectable labels normally claim only their text galley. In the remaining cell whitespace, + // egui_table's backing ScrollArea receives the drag and pans instead of starting selection. + // Change only the hit-test area so text layout and painting remain tied to the label itself. + response.interact_rect = ui.clip_rect(); + + // Re-register the same widget so egui hit testing sees the expanded rectangle this pass. + let sense = response.sense; + response.interact(sense) } fn render_plain_cell( diff --git a/crates/app/src/session/ui/common/mod.rs b/crates/app/src/session/ui/common/mod.rs index ebb6834459..71d3ca32e2 100644 --- a/crates/app/src/session/ui/common/mod.rs +++ b/crates/app/src/session/ui/common/mod.rs @@ -1,3 +1,4 @@ pub mod ansi_text; +pub mod attachment_preview; pub mod log_table; pub mod logs_mapped; diff --git a/crates/app/src/session/ui/logs_table/mod.rs b/crates/app/src/session/ui/logs_table/mod.rs index a9a9ada4c7..6c9fde8ba1 100644 --- a/crates/app/src/session/ui/logs_table/mod.rs +++ b/crates/app/src/session/ui/logs_table/mod.rs @@ -23,6 +23,7 @@ use crate::{ self, log_table::{ LogTableKind, + copy::{self, CopyScope}, table::{ self, TableScroll, activate_table_on_click, apply_columns_to_table_state, columns_filling_last, grab_cmd_consts, render_active_table_indicator, @@ -152,12 +153,13 @@ impl LogsTable { actions: &mut UiActions, ui: &mut Ui, ) { + copy::render_copy_action(shared, CopyScope::AllSelected, actions, &self.cmd_tx, ui); table::render_unselect_action(shared, ui); let selected_count = shared.logs.selected_count(); let can_start_export = shared.exports.can_start(); - let selected_target = ExportTarget::Rows(shared.logs.selected_rows()); + let selected_target = ExportTarget::Rows(shared.logs.selected_rows().collect()); let selected_label = export::rendered_text_export_label(shared.schema.as_ref(), &selected_target); if ui @@ -190,7 +192,7 @@ impl LogsTable { .add_enabled(can_export_raw, egui::Button::new(selected_export_label)) .clicked() { - let target = ExportTarget::Rows(shared.logs.selected_rows()); + let target = ExportTarget::Rows(shared.logs.selected_rows().collect()); let file_name = export::default_raw_file_name(shared); shared.exports.open_raw_dialog( actions, diff --git a/crates/app/src/session/ui/mod.rs b/crates/app/src/session/ui/mod.rs index 44d1135002..1603f80949 100644 --- a/crates/app/src/session/ui/mod.rs +++ b/crates/app/src/session/ui/mod.rs @@ -62,6 +62,7 @@ mod shared; mod shortcuts; mod side_panel; mod status_bar; +mod tab_context_menu; pub use bottom_panel::chart; pub use recent::RecentSessionRuntime; @@ -240,7 +241,7 @@ impl Session { export_modal::render_content(&mut shared.exports, actions, ui); self.attachment_modal - .render_content(&mut shared.attachments, ui); + .render_content(&mut shared.attachments, actions, ui); self.handle_signals(registry, preferences); } @@ -296,6 +297,7 @@ impl Session { /// Check incoming messages and handle them. pub fn handle_messages( &mut self, + ctx: &Context, actions: &mut UiActions, storage: &mut HostStorage, registry: &HostRegistry, @@ -320,6 +322,23 @@ impl Session { .handle_selected_log(selected_row, selected); } } + SessionMessage::CopyRowsLoaded(result) => { + let Some(rows) = self.ok_or_notify(result, actions) else { + continue; + }; + let row_count = rows.len(); + let text = + common::log_table::copy::format_rows(rows, self.shared.schema.as_ref()); + if !text.is_empty() { + ctx.copy_text(text); + + let message = match row_count { + 1 => "Copied 1 row to clipboard.".to_owned(), + count => format!("Copied {count} rows to clipboard."), + }; + actions.add_transient_notification(AppNotification::Info(message)); + } + } SessionMessage::SearchResultCountUpdated { count } => { self.shared.search.set_search_result_count(count); if !self.nested_search_available() { @@ -685,6 +704,16 @@ impl Session { .nested_focused(self.get_info().id, ctx) } + /// Handles session-scoped copy behavior, returning `true` when the event is consumed. + pub fn handle_copy_event( + &mut self, + actions: &mut UiActions, + preferences: &HostPreferences, + ctx: &Context, + ) -> bool { + shortcuts::handle_copy_event(self, actions, preferences, ctx) + } + /// Handles session shortcuts, returning `true` when a shortcut is consumed. pub fn handle_shortcuts( &mut self, @@ -1073,7 +1102,10 @@ mod tests { assert!(session.shared.logs.take_main_row_focus().is_none()); let notifications: Vec<_> = actions.drain_notifications().collect(); assert_eq!(notifications.len(), 1); - assert_matches!(notifications.first(), Some(AppNotification::Info(_))); + assert_matches!( + notifications.first().map(|request| &request.notification), + Some(AppNotification::Info(_)) + ); } #[test] diff --git a/crates/app/src/session/ui/shared/export/mod.rs b/crates/app/src/session/ui/shared/export/mod.rs index 2de61f6266..eae362af06 100644 --- a/crates/app/src/session/ui/shared/export/mod.rs +++ b/crates/app/src/session/ui/shared/export/mod.rs @@ -251,6 +251,8 @@ pub fn full_row_text_options() -> TextExportOptions { #[cfg(test)] mod tests { + use std::assert_matches; + use super::*; use crate::host::notification::AppNotification; @@ -283,7 +285,7 @@ mod tests { let notifications = actions.drain_notifications().collect::>(); assert_eq!(notifications.len(), 1); - assert!(matches!(notifications[0], AppNotification::Info(_))); + assert_matches!(notifications[0].notification, AppNotification::Info(_)); } #[test] diff --git a/crates/app/src/session/ui/shared/logs.rs b/crates/app/src/session/ui/shared/logs.rs index 6a9b1df54f..de4c293aa7 100644 --- a/crates/app/src/session/ui/shared/logs.rs +++ b/crates/app/src/session/ui/shared/logs.rs @@ -122,19 +122,17 @@ impl LogsState { self.selected_rows.len() } + /// Iterates over selected stream positions in unspecified order. + pub fn selected_rows(&self) -> impl Iterator + '_ { + self.selected_rows.iter().copied() + } + /// 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 { diff --git a/crates/app/src/session/ui/shared/searching/search.rs b/crates/app/src/session/ui/shared/searching/search.rs index 86359181ea..a9a5ffa79e 100644 --- a/crates/app/src/session/ui/shared/searching/search.rs +++ b/crates/app/src/session/ui/shared/searching/search.rs @@ -256,6 +256,13 @@ impl SearchState { *next_search_result_index = 0; } + /// Returns whether a session position belongs to the current primary search results. + pub fn has_match(&self, session_position: u64) -> bool { + self.matches_map + .as_ref() + .is_some_and(|matches| matches.contains_key(&LogMainIndex(session_position))) + } + /// Returns the primary filter indices reported for one session position. pub fn filter_indices(&self, session_position: u64) -> Option<&[FilterIndex]> { self.matches_map diff --git a/crates/app/src/session/ui/shortcuts.rs b/crates/app/src/session/ui/shortcuts.rs index 1bb3b1e4d4..47785a78dd 100644 --- a/crates/app/src/session/ui/shortcuts.rs +++ b/crates/app/src/session/ui/shortcuts.rs @@ -1,4 +1,4 @@ -use egui::{Context, Key, KeyboardShortcut, Modifiers}; +use egui::{Context, Event, Key, KeyboardShortcut, Modifiers}; use session_core::state::IndexedNavigation; @@ -10,14 +10,21 @@ use crate::{ matching::{consume_outside_text, consume_shortcut}, state::{LastShortcutKey, ShortcutAction}, }, - state::HostState, + state::{HostPreferences, HostState}, }, session::command::SessionCommand, }; use super::{ - Session, bottom_panel::BottomTabType, common::log_table::table::TableScroll, - shared::BookmarkNavigation, side_panel::SideTabType, + Session, + bottom_panel::BottomTabType, + common::log_table::{ + LogTableKind, + copy::{self, COPY_ROWS_SHORTCUT, CopyScope}, + table::TableScroll, + }, + shared::BookmarkNavigation, + side_panel::SideTabType, }; const ACTIVE_PAGE_UP_BINDINGS: &[KeyboardShortcut] = &[ @@ -258,6 +265,40 @@ pub fn shortcut_defs() -> [&'static Shortcut; 23] { ] } +/// Handles session-scoped copy behavior, returning `true` when the event is consumed. +pub fn handle_copy_event( + session: &mut Session, + actions: &mut UiActions, + preferences: &HostPreferences, + ctx: &Context, +) -> bool { + // Selected rows use shifted copy because session routing cannot reliably determine whether + // manually selected label text owns plain Ctrl/Cmd+C; leave that event for egui. + + if ctx.text_edit_focused() + || !ctx.input(|input| input.modifiers.matches_exact(COPY_ROWS_SHORTCUT.modifiers)) + { + return false; + } + + // Copy the active table's selected rows, treating a hidden search table as inactive. + let search_table_visible = + preferences.panels_visibility.bottom && session.shared.bottom_tab == BottomTabType::Search; + let scope = match session.shared.view.active_log_table { + LogTableKind::Search if search_table_visible => CopyScope::SearchRows, + LogTableKind::Main | LogTableKind::Search => CopyScope::AllSelected, + }; + + if !copy::copy_selected_rows(&session.shared, scope, actions, &session.cmd_tx) { + return false; + } + + ctx.input_mut(|input| { + input.events.retain(|event| !matches!(event, Event::Copy)); + }); + true +} + /// Handles session shortcuts, returning `true` when a shortcut is consumed. pub fn handle( session: &mut Session, diff --git a/crates/app/src/session/ui/side_panel/attachments.rs b/crates/app/src/session/ui/side_panel/attachments.rs index 8c59e25e9f..223f3d0004 100644 --- a/crates/app/src/session/ui/side_panel/attachments.rs +++ b/crates/app/src/session/ui/side_panel/attachments.rs @@ -6,7 +6,8 @@ use log::error; use rustc_hash::FxHashSet; use egui::{ - Frame, Label, Layout, Modifiers, RichText, Spinner, TextureHandle, Ui, UiBuilder, Widget, vec2, + Frame, Label, Layout, Modifiers, Rect, RichText, Spinner, TextureHandle, Ui, UiBuilder, Widget, + vec2, }; use stypes::AttachmentInfo; use tokio::sync::mpsc; @@ -29,6 +30,7 @@ use crate::{ PreviewContent, PreviewKind, PreviewRequest, PreviewTarget, kind_for_mime, }, ui::{ + common::attachment_preview::render_copy_button, shared::{AttachmentsState, SearchTableSync, SessionShared}, side_panel::TITLE_SIZE, }, @@ -282,28 +284,34 @@ impl AttachmentsUi { content, } if *attachment_id == attachment.uuid => { can_open_preview = true; - match content { - PreviewContent::Text(txt) => { + let (frame_rect, preview_clicked) = match content { + PreviewContent::Text(text) => ( Self::render_text_preview_frame( ui, - txt, + text, &mut self.reset_text_scroll, - ); + ), + false, + ), + PreviewContent::Image(image) => { + Self::render_image_preview_frame(ui, image.texture()) } - PreviewContent::Image(texture_handle) => { - image_clicked = - Self::render_image_preview_frame(ui, texture_handle) - } - } + }; + let copy_clicked = + render_copy_button(ui, frame_rect, content, ui_actions); + image_clicked = preview_clicked && !copy_clicked; } PreviewState::NotSupported { attachment_id } if *attachment_id == attachment.uuid => { render_centered_preview_status(ui, |ui| { - ui.label( + Label::new( RichText::new("Preview unavailable for this attachment type.") .weak(), - ); + ) + .wrap() + .halign(egui::Align::Center) + .ui(ui); }); } _ => return, @@ -837,11 +845,12 @@ impl AttachmentsUi { (self.preview_panel_height_index + 1) % PREVIEW_PANEL_HEIGHTS.len(); } - fn render_text_preview_frame(ui: &mut Ui, content: &str, reset_scroll: &mut bool) { + fn render_text_preview_frame(ui: &mut Ui, content: &str, reset_scroll: &mut bool) -> Rect { const PREVIEW_FRAME_INNER_MARGIN: f32 = 8.0; + let mut frame_rect = Rect::NOTHING; ui.with_layout(Layout::top_down(egui::Align::Min), |ui| { - Frame::NONE + frame_rect = Frame::NONE .inner_margin(egui::Margin::same(PREVIEW_FRAME_INNER_MARGIN as i8)) .stroke(ui.visuals().widgets.noninteractive.bg_stroke) .show(ui, |ui| { @@ -857,16 +866,19 @@ impl AttachmentsUi { .extend() .ui(ui); }); - }); + }) + .response + .rect; }); + frame_rect } - fn render_image_preview_frame(ui: &mut Ui, texture: &TextureHandle) -> bool { + fn render_image_preview_frame(ui: &mut Ui, texture: &TextureHandle) -> (Rect, bool) { const PREVIEW_FRAME_INNER_MARGIN: f32 = 8.0; let image_size = texture.size_vec2(); if image_size.x <= 0.0 || image_size.y <= 0.0 { - return false; + return (Rect::NOTHING, false); } let margin = egui::Vec2::splat(2.0 * PREVIEW_FRAME_INNER_MARGIN); @@ -874,10 +886,10 @@ impl AttachmentsUi { let scale = (max_image_size / image_size).min_elem().clamp(0.0, 1.0); let preview_size = image_size * scale; + let mut frame_rect = Rect::NOTHING; let mut clicked = false; ui.with_layout(Layout::top_down(egui::Align::Center), |ui| { - let (frame_rect, _) = - ui.allocate_exact_size(preview_size + margin, egui::Sense::hover()); + (frame_rect, _) = ui.allocate_exact_size(preview_size + margin, egui::Sense::hover()); ui.painter().rect_stroke( frame_rect, 4.0, @@ -894,7 +906,7 @@ impl AttachmentsUi { .clicked(); }); - clicked + (frame_rect, clicked) } } diff --git a/crates/app/src/session/ui/side_panel/filters/actions.rs b/crates/app/src/session/ui/side_panel/filters/actions.rs index e499dbe23f..eed5e6c997 100644 --- a/crates/app/src/session/ui/side_panel/filters/actions.rs +++ b/crates/app/src/session/ui/side_panel/filters/actions.rs @@ -407,7 +407,7 @@ impl FiltersUi { #[cfg(test)] mod tests { - use std::path::PathBuf; + use std::{assert_matches, path::PathBuf}; use processor::search::filter::SearchFilter; use stypes::{FileFormat, ObserveOrigin}; @@ -522,10 +522,13 @@ mod tests { Some(¤t_filter) ); assert!(cmd_rx.try_recv().is_err()); - assert!(matches!( - actions.drain_notifications().next(), + assert_matches!( + actions + .drain_notifications() + .next() + .map(|request| request.notification), Some(AppNotification::Warning(_)) - )); + ); assert!(shared.signals.is_empty()); } } diff --git a/crates/app/src/session/ui/tab_context_menu.rs b/crates/app/src/session/ui/tab_context_menu.rs new file mode 100644 index 0000000000..8fe0ed1345 --- /dev/null +++ b/crates/app/src/session/ui/tab_context_menu.rs @@ -0,0 +1,322 @@ +//! Session-tab clipboard commands for observed source metadata. + +use egui::Ui; +use stypes::{ObserveOrigin, Transport}; + +use crate::{ + host::{notification::AppNotification, ui::UiActions}, + session::ui::Session, +}; + +impl Session { + /// Renders clipboard commands for this session's tab. + /// + /// Returns `true` when at least one tab-specific command was rendered. + pub fn render_tab_context_menu(&self, actions: &mut UiActions, ui: &mut Ui) -> bool { + let Some(first_operation) = self.shared.observe.operations().first() else { + return false; + }; + + match &first_operation.origin { + ObserveOrigin::File(..) | ObserveOrigin::Concat(..) => { + self.render_file_tab_menu(actions, ui) + } + ObserveOrigin::Stream(_, Transport::Process(..)) => { + self.render_process_tab_menu(actions, ui) + } + ObserveOrigin::Stream(_, Transport::TCP(..)) => { + self.render_address_tab_menu(actions, ui, tcp_address) + } + ObserveOrigin::Stream(_, Transport::UDP(..)) => { + self.render_address_tab_menu(actions, ui, udp_address) + } + ObserveOrigin::Stream(_, Transport::Serial(..)) => { + self.render_serial_tab_menu(actions, ui) + } + } + } + + /// Renders clipboard commands for the session's observed files. + /// + /// Returns `true` when at least one file command was rendered. + fn render_file_tab_menu(&self, actions: &mut UiActions, ui: &mut Ui) -> bool { + let file_count = self.shared.observe.sources_count(); + if file_count == 0 { + return false; + } + + let path_label = match file_count { + 1 => String::from("Copy File Path"), + count => format!("Copy {count} File Paths"), + }; + if ui.button(path_label).clicked() { + let paths = self.file_path_text(); + ui.ctx().copy_text(paths); + + let message = match file_count { + 1 => String::from("Copied 1 file path to clipboard."), + count => format!("Copied {count} file paths to clipboard."), + }; + actions.add_transient_notification(AppNotification::Info(message)); + ui.close(); + } + + let file_name_count = self.file_name_count(); + if file_name_count > 0 { + let name_label = match file_name_count { + 1 => String::from("Copy File Name"), + count => format!("Copy {count} File Names"), + }; + if ui.button(name_label).clicked() { + let names = self.file_name_text(); + ui.ctx().copy_text(names); + + let message = match file_name_count { + 1 => String::from("Copied 1 file name to clipboard."), + count => format!("Copied {count} file names to clipboard."), + }; + actions.add_transient_notification(AppNotification::Info(message)); + ui.close(); + } + } + + true + } + + /// Renders a clipboard command for the session's terminal commands. + /// + /// Returns `true` when at least one process command was rendered. + fn render_process_tab_menu(&self, actions: &mut UiActions, ui: &mut Ui) -> bool { + let command_count = self + .shared + .observe + .operations() + .iter() + .filter(|operation| { + matches!( + &operation.origin, + ObserveOrigin::Stream(_, Transport::Process(..)) + ) + }) + .count(); + if command_count == 0 { + return false; + } + + let label = match command_count { + 1 => String::from("Copy Command"), + count => format!("Copy {count} Commands"), + }; + if ui.button(label).clicked() { + let commands = self.process_command_text(); + ui.ctx().copy_text(commands); + + let message = match command_count { + 1 => String::from("Copied 1 command to clipboard."), + count => format!("Copied {count} commands to clipboard."), + }; + actions.add_transient_notification(AppNotification::Info(message)); + ui.close(); + } + + true + } + + /// Renders a clipboard command for the session's network addresses. + /// + /// Returns `true` when at least one address command was rendered. + fn render_address_tab_menu( + &self, + actions: &mut UiActions, + ui: &mut Ui, + address_for: fn(&Transport) -> Option<&str>, + ) -> bool { + let address_count = self + .shared + .observe + .operations() + .iter() + .filter_map(|operation| match &operation.origin { + ObserveOrigin::Stream(_, transport) => address_for(transport), + ObserveOrigin::File(..) | ObserveOrigin::Concat(..) => None, + }) + .count(); + if address_count == 0 { + return false; + } + + let label = match address_count { + 1 => String::from("Copy Address"), + count => format!("Copy {count} Addresses"), + }; + if ui.button(label).clicked() { + let addresses = self.address_text(address_for); + ui.ctx().copy_text(addresses); + + let message = match address_count { + 1 => String::from("Copied 1 address to clipboard."), + count => format!("Copied {count} addresses to clipboard."), + }; + actions.add_transient_notification(AppNotification::Info(message)); + ui.close(); + } + + true + } + + /// Renders a clipboard command for the session's serial ports. + /// + /// Returns `true` when at least one serial-port command was rendered. + fn render_serial_tab_menu(&self, actions: &mut UiActions, ui: &mut Ui) -> bool { + let port_count = self + .shared + .observe + .operations() + .iter() + .filter(|operation| { + matches!( + &operation.origin, + ObserveOrigin::Stream(_, Transport::Serial(..)) + ) + }) + .count(); + if port_count == 0 { + return false; + } + + let label = match port_count { + 1 => String::from("Copy Port"), + count => format!("Copy {count} Ports"), + }; + if ui.button(label).clicked() { + let ports = self.serial_port_text(); + ui.ctx().copy_text(ports); + + let message = match port_count { + 1 => String::from("Copied 1 port to clipboard."), + count => format!("Copied {count} ports to clipboard."), + }; + actions.add_transient_notification(AppNotification::Info(message)); + ui.close(); + } + + true + } + + fn file_path_text(&self) -> String { + let mut text = String::new(); + let mut line_count = 0; + for operation in self.shared.observe.operations() { + match &operation.origin { + ObserveOrigin::File(_, _, path) => { + append_line(&mut text, &path.to_string_lossy(), &mut line_count); + } + ObserveOrigin::Concat(files) => { + for (_, _, path) in files { + append_line(&mut text, &path.to_string_lossy(), &mut line_count); + } + } + ObserveOrigin::Stream(..) => {} + } + } + text + } + + fn file_name_count(&self) -> usize { + self.shared + .observe + .operations() + .iter() + .map(|operation| match &operation.origin { + ObserveOrigin::File(_, _, path) => { + let has_file_name = path.file_name().is_some(); + usize::from(has_file_name) + } + ObserveOrigin::Concat(files) => files + .iter() + .filter(|(_, _, path)| path.file_name().is_some()) + .count(), + ObserveOrigin::Stream(..) => 0, + }) + .sum() + } + + fn file_name_text(&self) -> String { + let mut text = String::new(); + let mut line_count = 0; + for operation in self.shared.observe.operations() { + match &operation.origin { + ObserveOrigin::File(_, _, path) => { + if let Some(name) = path.file_name() { + append_line(&mut text, &name.to_string_lossy(), &mut line_count); + } + } + ObserveOrigin::Concat(files) => { + for name in files.iter().filter_map(|(_, _, path)| path.file_name()) { + append_line(&mut text, &name.to_string_lossy(), &mut line_count); + } + } + ObserveOrigin::Stream(..) => {} + } + } + text + } + + fn process_command_text(&self) -> String { + let mut text = String::new(); + let mut line_count = 0; + for operation in self.shared.observe.operations() { + if let ObserveOrigin::Stream(_, Transport::Process(config)) = &operation.origin { + append_line(&mut text, &config.command, &mut line_count); + } + } + text + } + + fn address_text(&self, address_for: fn(&Transport) -> Option<&str>) -> String { + let mut text = String::new(); + let mut line_count = 0; + for operation in self.shared.observe.operations() { + let ObserveOrigin::Stream(_, transport) = &operation.origin else { + continue; + }; + if let Some(address) = address_for(transport) { + append_line(&mut text, address, &mut line_count); + } + } + text + } + + fn serial_port_text(&self) -> String { + let mut text = String::new(); + let mut line_count = 0; + for operation in self.shared.observe.operations() { + if let ObserveOrigin::Stream(_, Transport::Serial(config)) = &operation.origin { + append_line(&mut text, &config.path, &mut line_count); + } + } + text + } +} + +fn tcp_address(transport: &Transport) -> Option<&str> { + match transport { + Transport::TCP(config) => Some(&config.bind_addr), + Transport::Process(..) | Transport::UDP(..) | Transport::Serial(..) => None, + } +} + +fn udp_address(transport: &Transport) -> Option<&str> { + match transport { + Transport::UDP(config) => Some(&config.bind_addr), + Transport::Process(..) | Transport::TCP(..) | Transport::Serial(..) => None, + } +} + +fn append_line(text: &mut String, line: &str, line_count: &mut usize) { + if *line_count > 0 { + text.push('\n'); + } + text.push_str(line); + *line_count += 1; +}