diff --git a/i18n/en/cosmic_term.ftl b/i18n/en/cosmic_term.ftl index 686e9b5..700085b 100644 --- a/i18n/en/cosmic_term.ftl +++ b/i18n/en/cosmic_term.ftl @@ -104,6 +104,8 @@ type-to-search = Type to search... find-placeholder = Find... find-previous = Find previous find-next = Find next +find-case-sensitive = Case sensitive +find-regex = Regex # Menu @@ -123,6 +125,12 @@ paste = Paste select-all = Select all find = Find clear-scrollback = Clear scrollback +save-output = Save output as text... + +## Monitors +monitor-activity = Monitor for activity +monitor-silence = Monitor for silence +monitor-process-finished = Monitor for process finishing ## Open open-link = Open Link diff --git a/i18n/fr/cosmic_term.ftl b/i18n/fr/cosmic_term.ftl index 8351497..8fe64fc 100644 --- a/i18n/fr/cosmic_term.ftl +++ b/i18n/fr/cosmic_term.ftl @@ -72,6 +72,8 @@ show-header-description = Révéler l'en-tête du menu contextuel find-placeholder = Rechercher... find-previous = Chercher précédent find-next = Chercher prochain +find-case-sensitive = Respecter la casse +find-regex = Regex # Menu @@ -94,6 +96,12 @@ paste = Coller select-all = Sélectionner tout find = Rechercher clear-scrollback = Effacer l'historique +save-output = Enregistrer la sortie comme texte... + +## Monitors +monitor-activity = Surveiller l'activité +monitor-silence = Surveiller le silence +monitor-process-finished = Surveiller la fin du processus ## View diff --git a/src/main.rs b/src/main.rs index 005cbc3..180eb18 100644 --- a/src/main.rs +++ b/src/main.rs @@ -65,7 +65,7 @@ mod localize; use menu::menu_bar; mod menu; -use terminal::{Terminal, TerminalPaneGrid, TerminalScroll}; +use terminal::{MONITOR_SILENCE_SECONDS, MonitorKind, Terminal, TerminalPaneGrid, TerminalScroll}; mod terminal; use terminal_box::terminal_box; @@ -89,6 +89,33 @@ pub fn icon_cache_get(name: &'static str, size: u16) -> widget::icon::Icon { icon_cache.get(name, size) } +/// Tab bar indicator for the monitor state of a terminal: +/// an alert icon if one fired, an "armed" icon while monitoring, else none. +fn monitor_icon_name(terminal: &Terminal) -> Option<&'static str> { + match terminal.monitor_alert { + Some(MonitorKind::Activity) => Some("dialog-information-symbolic"), + Some(MonitorKind::Silence) => Some("alarm-symbolic"), + Some(MonitorKind::ProcessFinished) => Some("emblem-ok-symbolic"), + None if terminal.monitoring_enabled() => Some("view-reveal-symbolic"), + None => None, + } +} + +fn monitor_update_tab_icon( + tab_model: &mut segmented_button::Model, + entity: segmented_button::Entity, + icon_name: Option<&'static str>, +) { + match icon_name { + Some(name) => { + tab_model.icon_set(entity, icon_cache_get(name, 16)); + } + None => { + tab_model.icon_remove(entity); + } + } +} + /// Runs application with these settings #[rustfmt::skip] fn main() -> Result<(), Box> { @@ -99,6 +126,8 @@ fn main() -> Result<(), Box> { let mut shell_args = Vec::new(); let mut daemonize = true; let mut working_directory = None; + let mut profile_name_opt = None; + let mut list_profiles = false; // Parse the arguments using clap_lex while let Some(arg) = raw_args.next_os(&mut cursor) { match arg.to_str() { @@ -121,6 +150,17 @@ fn main() -> Result<(), Box> { process::exit(1); } } + Some(arg_str @ "--profile") | Some(arg_str @ "-p") => { + if let Some(profile_arg) = raw_args.next_os(&mut cursor) { + profile_name_opt = Some(profile_arg.to_string_lossy().to_string()); + } else { + eprintln!("Missing argument for {arg_str}"); + process::exit(1); + } + } + Some("--list-profiles") => { + list_profiles = true; + } Some("--no-daemon") => { daemonize = false; } @@ -143,22 +183,8 @@ fn main() -> Result<(), Box> { } } - // Platform-specific daemonization logic - - #[cfg(all(unix, not(target_os = "redox")))] - if daemonize { - match fork::daemon(true, true) { - Ok(fork::Fork::Child) => (), - Ok(fork::Fork::Parent(_child_pid)) => process::exit(0), - Err(err) => { - eprintln!("failed to daemonize: {:?}", err); - process::exit(1); - } - } - } - - localize::localize(); - + // Load config before daemonizing so profile CLI options can print + // errors and listings to the launching terminal let (config_handler, config) = match cosmic_config::Config::new(App::APP_ID, CONFIG_VERSION) { Ok(config_handler) => { let config = match Config::get_entry(&config_handler) { @@ -176,6 +202,53 @@ fn main() -> Result<(), Box> { } }; + if list_profiles { + for (name, profile_id) in config.profile_names() { + println!("{}\t{}", profile_id.0, name); + } + return Ok(()); + } + + let startup_profile_id = match &profile_name_opt { + Some(profile_name) => { + let profile_id_opt = config + .profile_names() + .into_iter() + .find(|(name, _)| name == profile_name) + .map(|(_, profile_id)| profile_id) + .or_else(|| { + // Fall back to matching a numeric profile ID + let id = profile_name.parse::().ok()?; + config.profiles.contains_key(&ProfileId(id)).then_some(ProfileId(id)) + }); + if profile_id_opt.is_none() { + eprintln!("Profile {profile_name:?} not found. Available profiles:"); + for (name, profile_id) in config.profile_names() { + eprintln!("{}\t{}", profile_id.0, name); + } + process::exit(1); + } + profile_id_opt + } + None => None, + }; + + // Platform-specific daemonization logic + + #[cfg(all(unix, not(target_os = "redox")))] + if daemonize { + match fork::daemon(true, true) { + Ok(fork::Fork::Child) => (), + Ok(fork::Fork::Parent(_child_pid)) => process::exit(0), + Err(err) => { + eprintln!("failed to daemonize: {:?}", err); + process::exit(1); + } + } + } + + localize::localize(); + let shortcuts_config = shortcuts::ShortcutsConfig::new(config.shortcuts_custom.clone()); let shell = shell_program_opt.map(|shell_program| tty::Shell::new(shell_program, shell_args)); @@ -208,6 +281,7 @@ fn main() -> Result<(), Box> { config, shortcuts_config, startup_options, + startup_profile_id, term_config, }; @@ -226,7 +300,13 @@ Project home page: https://github.com/pop-os/cosmic-term Options: --help Show this message --version Show the version of cosmic-term - -w, --working-directory Set the working directory for the terminal"# + -w, --working-directory Set the working directory for the terminal + -p, --profile Open the first tab with the given profile + --list-profiles List profiles (ID and name) and exit + --no-daemon Do not detach from the launching terminal + -e, --command Run a command instead of the default shell; + everything after this flag (or after `--`) + is treated as the command and its arguments"# ); } @@ -236,6 +316,7 @@ pub struct Flags { config: Config, shortcuts_config: shortcuts::ShortcutsConfig, startup_options: Option, + startup_profile_id: Option, term_config: term::Config, } @@ -251,6 +332,7 @@ pub enum Action { Find, KeyboardShortcuts, LaunchUrlByMenu, + Monitor(MonitorKind), PaneFocusDown, PaneFocusLeft, PaneFocusRight, @@ -262,6 +344,7 @@ pub enum Action { PastePrimary, ProfileOpen(ProfileId), Profiles, + SaveOutput, SelectAll, Settings, #[cfg(feature = "password_manager")] @@ -305,6 +388,7 @@ impl Action { Self::Find => Message::Find(true), Self::KeyboardShortcuts => Message::ToggleContextPage(ContextPage::KeyboardShortcuts), Self::LaunchUrlByMenu => Message::LaunchUrlByMenu, + Self::Monitor(monitor_kind) => Message::MonitorToggle(*monitor_kind, entity_opt), Self::PaneFocusDown => Message::PaneFocusAdjacent(pane_grid::Direction::Down), Self::PaneFocusLeft => Message::PaneFocusAdjacent(pane_grid::Direction::Left), Self::PaneFocusRight => Message::PaneFocusAdjacent(pane_grid::Direction::Right), @@ -318,6 +402,7 @@ impl Action { Self::PastePrimary => Message::PastePrimary(entity_opt), Self::ProfileOpen(profile_id) => Message::ProfileOpen(*profile_id), Self::Profiles => Message::ToggleContextPage(ContextPage::Profiles), + Self::SaveOutput => Message::SaveOutput(entity_opt), Self::SelectAll => Message::SelectAll(entity_opt), Self::Settings => Message::ToggleContextPage(ContextPage::Settings), Self::ShowHeaderBar(show_headerbar) => Message::ShowHeaderBar(*show_headerbar), @@ -384,8 +469,10 @@ pub enum Message { DialogMessage(Box), // DialogMessage is huge, so we use a box to make the size of this enum smaller on the stack Drop(Option<(pane_grid::Pane, segmented_button::Entity, DndDrop)>), Find(bool), + FindCaseSensitive(bool), FindNext, FindPrevious, + FindRegex(bool), FindSearchValueChanged(String), MiddleClick(pane_grid::Pane, Option), FocusFollowMouse(bool), @@ -400,6 +487,8 @@ pub enum Message { ShortcutRemove(shortcuts::Binding, shortcuts::BindingSource), ShortcutReset(shortcuts::KeyBindAction), ShortcutSearch(String), + MonitorTick, + MonitorToggle(MonitorKind, Option), MouseEnter(pane_grid::Pane), Opacity(u8), PaneClicked(pane_grid::Pane), @@ -427,6 +516,8 @@ pub enum Message { ProfileSyntaxTheme(ProfileId, ColorSchemeKind, usize), ProfileTabTitle(ProfileId, String), ReorderTab(Pane, ReorderEvent), + SaveOutput(Option), + SaveOutputResult(pane_grid::Pane, segmented_button::Entity, DialogResult), Surface(surface::Action), SelectAll(Option), ShowAdvancedFontSettings(bool), @@ -512,11 +603,14 @@ pub struct App { dialog_opt: Option>, terminal_ids: HashMap, find: bool, + find_case_sensitive: bool, + find_regex: bool, find_search_id: widget::Id, find_search_value: String, term_event_tx_opt: Option>, startup_options: Option, + startup_profile_id: Option, term_config: term::Config, color_scheme_errors: Vec, color_scheme_expanded: Option<(ColorSchemeKind, Option)>, @@ -1554,6 +1648,60 @@ impl App { terminal.working_directory() } + /// Build the regex pattern for the find bar based on the current options. + fn find_pattern(&self) -> String { + let mut pattern = if self.find_regex { + self.find_search_value.clone() + } else { + regex::escape(&self.find_search_value) + }; + if !self.find_case_sensitive { + pattern = format!("(?i){pattern}"); + } + pattern + } + + fn terminal_monitors( + &self, + pane: pane_grid::Pane, + entity: segmented_button::Entity, + ) -> (bool, bool, bool) { + if let Some(tab_model) = self.pane_model.panes.get(pane) + && let Some(terminal) = tab_model.data::>(entity) + { + let terminal = terminal.lock().unwrap(); + ( + terminal.monitor_activity, + terminal.monitor_silence, + terminal.monitor_process_finished, + ) + } else { + (false, false, false) + } + } + + fn active_terminal_monitors(&self) -> (bool, bool, bool) { + let pane = self.pane_model.focused(); + if let Some(tab_model) = self.pane_model.panes.get(pane) { + self.terminal_monitors(pane, tab_model.active()) + } else { + (false, false, false) + } + } + + fn any_monitor_needs_tick(&self) -> bool { + self.pane_model.panes.iter().any(|(_pane, tab_model)| { + tab_model.iter().any(|entity| { + tab_model + .data::>(entity) + .is_some_and(|terminal| { + let terminal = terminal.lock().unwrap(); + terminal.monitor_silence || terminal.monitor_process_finished + }) + }) + }) + } + fn create_and_focus_new_terminal( &mut self, pane: pane_grid::Pane, @@ -1887,9 +2035,12 @@ impl Application for App { dialog_opt: None, terminal_ids, find: false, + find_case_sensitive: false, + find_regex: false, find_search_id: widget::Id::unique(), find_search_value: String::new(), startup_options: flags.startup_options, + startup_profile_id: flags.startup_profile_id, term_config: flags.term_config, term_event_tx_opt: None, color_scheme_errors: Vec::new(), @@ -2401,14 +2552,19 @@ impl Application for App { // Focus correct input return self.update_focus(); } + Message::FindCaseSensitive(case_sensitive) => { + self.find_case_sensitive = case_sensitive; + return self.update_focus(); + } Message::FindNext => { - if !self.find_search_value.is_empty() - && let Some(tab_model) = self.pane_model.active() - { - let entity = tab_model.active(); - if let Some(terminal) = tab_model.data::>(entity) { - let mut terminal = terminal.lock().unwrap(); - terminal.search(&self.find_search_value, true); + if !self.find_search_value.is_empty() { + let pattern = self.find_pattern(); + if let Some(tab_model) = self.pane_model.active() { + let entity = tab_model.active(); + if let Some(terminal) = tab_model.data::>(entity) { + let mut terminal = terminal.lock().unwrap(); + terminal.search(&pattern, true); + } } } @@ -2416,19 +2572,24 @@ impl Application for App { return self.update_focus(); } Message::FindPrevious => { - if !self.find_search_value.is_empty() - && let Some(tab_model) = self.pane_model.active() - { - let entity = tab_model.active(); - if let Some(terminal) = tab_model.data::>(entity) { - let mut terminal = terminal.lock().unwrap(); - terminal.search(&self.find_search_value, false); + if !self.find_search_value.is_empty() { + let pattern = self.find_pattern(); + if let Some(tab_model) = self.pane_model.active() { + let entity = tab_model.active(); + if let Some(terminal) = tab_model.data::>(entity) { + let mut terminal = terminal.lock().unwrap(); + terminal.search(&pattern, false); + } } } // Focus correct input return self.update_focus(); } + Message::FindRegex(regex) => { + self.find_regex = regex; + return self.update_focus(); + } Message::FindSearchValueChanged(value) => { self.find_search_value = value; } @@ -2533,6 +2694,79 @@ impl Application for App { Message::Modifiers(modifiers) => { self.modifiers = modifiers; } + Message::MonitorTick => { + let panes: Vec = self + .pane_model + .panes + .iter() + .map(|(pane, _)| *pane) + .collect(); + for pane in panes { + let Some(tab_model) = self.pane_model.panes.get_mut(pane) else { + continue; + }; + let entities: Vec = tab_model.iter().collect(); + for entity in entities { + let mut icon_update = None; + if let Some(terminal) = tab_model.data::>(entity) { + let mut terminal = terminal.lock().unwrap(); + + // Silence: alert when no output for the configured delay + if terminal.monitor_silence + && terminal.monitor_alert.is_none() + && terminal.monitor_last_output.elapsed().as_secs() + >= MONITOR_SILENCE_SECONDS + { + terminal.monitor_alert = Some(MonitorKind::Silence); + icon_update = Some(monitor_icon_name(&terminal)); + } + + // Process finished: alert when the foreground job goes away + if terminal.monitor_process_finished { + let job_running = + terminal.foreground_job_running().unwrap_or(false); + if terminal.monitor_job_running && !job_running { + terminal.monitor_alert = Some(MonitorKind::ProcessFinished); + icon_update = Some(monitor_icon_name(&terminal)); + } + terminal.monitor_job_running = job_running; + } + } + if let Some(icon_name) = icon_update { + monitor_update_tab_icon(tab_model, entity, icon_name); + } + } + } + } + Message::MonitorToggle(monitor_kind, entity_opt) => { + if let Some(tab_model) = self.pane_model.active_mut() { + let entity = entity_opt.unwrap_or_else(|| tab_model.active()); + let mut icon_update = None; + if let Some(terminal) = tab_model.data::>(entity) { + let mut terminal = terminal.lock().unwrap(); + match monitor_kind { + MonitorKind::Activity => { + terminal.monitor_activity = !terminal.monitor_activity; + } + MonitorKind::Silence => { + terminal.monitor_silence = !terminal.monitor_silence; + terminal.monitor_last_output = std::time::Instant::now(); + } + MonitorKind::ProcessFinished => { + terminal.monitor_process_finished = + !terminal.monitor_process_finished; + terminal.monitor_job_running = + terminal.foreground_job_running().unwrap_or(false); + } + } + terminal.monitor_alert = None; + icon_update = Some(monitor_icon_name(&terminal)); + } + if let Some(icon_name) = icon_update { + monitor_update_tab_icon(tab_model, entity, icon_name); + } + } + } Message::MouseEnter(pane) => { self.pane_model.set_focus(pane); return self.update_focus(); @@ -2772,6 +3006,57 @@ impl Application for App { return self.save_profiles(); } } + Message::SaveOutput(entity_opt) => { + if self.dialog_opt.is_none() { + let pane = self.pane_model.focused(); + if let Some(tab_model) = self.pane_model.panes.get(pane) { + let entity = entity_opt.unwrap_or_else(|| tab_model.active()); + let filename = format!( + "{}.txt", + tab_model + .text(entity) + .map(|title| title.replace(['/', '\0'], "-")) + .unwrap_or_else(|| fl!("new-terminal")) + ); + let (dialog, command) = Dialog::new( + DialogSettings::new().kind(DialogKind::SaveFile { filename }), + |msg| Message::DialogMessage(Box::new(msg)), + move |result| Message::SaveOutputResult(pane, entity, result), + ); + self.dialog_opt = Some(dialog); + return command; + } + } + } + Message::SaveOutputResult(pane, entity, result) => { + self.dialog_opt = None; + if let DialogResult::Open(paths) = result { + let path = paths[0].clone(); + if let Some(tab_model) = self.pane_model.panes.get(pane) + && let Some(terminal) = tab_model.data::>(entity) + { + let text = terminal.lock().unwrap().scrollback_text(); + // Write in a blocking task to avoid stalling the UI on + // large scrollbacks or slow storage + return Task::perform( + async move { + match tokio::task::spawn_blocking(move || fs::write(&path, text)) + .await + { + Ok(Ok(())) => {} + Ok(Err(err)) => { + log::error!("failed to save terminal output: {}", err); + } + Err(err) => { + log::error!("failed to save terminal output: {}", err); + } + } + }, + |()| action::none(), + ); + } + } + } Message::SelectAll(entity_opt) => { if let Some(tab_model) = self.pane_model.active() { let entity = entity_opt.unwrap_or_else(|| tab_model.active()); @@ -2827,6 +3112,19 @@ impl Application for App { Message::TabActivate(entity) => { if let Some(tab_model) = self.pane_model.active_mut() { tab_model.activate(entity); + + // Viewing the tab acknowledges any monitor alert + let mut icon_update = None; + if let Some(terminal) = tab_model.data::>(entity) { + let mut terminal = terminal.lock().unwrap(); + if terminal.monitor_alert.is_some() { + terminal.monitor_alert = None; + icon_update = Some(monitor_icon_name(&terminal)); + } + } + if let Some(icon_name) = icon_update { + monitor_update_tab_icon(tab_model, entity, icon_name); + } } return self.update_title(None); } @@ -3006,9 +3304,14 @@ impl Application for App { return cosmic::Task::batch(tasks); } Message::TabNew => { + // The profile from `--profile` only applies to the first tab + let profile_id_opt = self + .startup_profile_id + .take() + .or_else(|| self.get_default_profile()); return self.create_and_focus_new_terminal( self.pane_model.focused(), - self.get_default_profile(), + profile_id_opt, self.config.tab_new_inherit_working_directory, ); } @@ -3199,7 +3502,7 @@ impl Application for App { } return self.update_title(Some(pane)); } - TermEvent::MouseCursorDirty | TermEvent::Wakeup => { + TermEvent::MouseCursorDirty => { if let Some(tab_model) = self.pane_model.panes.get(pane) && let Some(terminal) = tab_model.data::>(entity) { @@ -3207,6 +3510,33 @@ impl Application for App { terminal.needs_update = true; } } + TermEvent::Wakeup => { + if let Some(tab_model) = self.pane_model.panes.get_mut(pane) { + let is_active_tab = tab_model.active() == entity; + let mut icon_update = None; + if let Some(terminal) = tab_model.data::>(entity) { + let mut terminal = terminal.lock().unwrap(); + terminal.needs_update = true; + if terminal.monitoring_enabled() { + terminal.monitor_last_output = std::time::Instant::now(); + if terminal.monitor_alert == Some(MonitorKind::Silence) { + terminal.monitor_alert = None; + icon_update = Some(monitor_icon_name(&terminal)); + } + if terminal.monitor_activity + && !is_active_tab + && terminal.monitor_alert.is_none() + { + terminal.monitor_alert = Some(MonitorKind::Activity); + icon_update = Some(monitor_icon_name(&terminal)); + } + } + } + if let Some(icon_name) = icon_update { + monitor_update_tab_icon(tab_model, entity, icon_name); + } + } + } TermEvent::ChildExit(_error_code) => { //Ignore this for now } @@ -3483,7 +3813,12 @@ impl Application for App { } fn header_start(&self) -> Vec> { - vec![menu_bar(&self.core, &self.config, &self.key_binds)] + vec![menu_bar( + &self.core, + &self.config, + &self.key_binds, + self.active_terminal_monitors(), + )] } fn header_end(&self) -> Vec> { @@ -3506,12 +3841,18 @@ impl Application for App { } fn view_window(&self, window_id: window::Id) -> Element<'_, Message> { - if let Some((popup_id, _pane, entity, ref link, ref autosize_id, _)) = + if let Some((popup_id, pane, entity, ref link, ref autosize_id, _)) = self.context_menu_popup && window_id == popup_id { return widget::autosize::autosize( - menu::context_menu(&self.config, &self.key_binds, entity, link.clone()), + menu::context_menu( + &self.config, + &self.key_binds, + entity, + link.clone(), + self.terminal_monitors(pane, entity), + ), autosize_id.clone(), ) .into(); @@ -3546,7 +3887,9 @@ impl Application for App { cosmic::iced::widget::container::Style { icon_color: Some(Color::from(cosmic.background(false).on)), text_color: Some(Color::from(cosmic.background(false).on)), - background: Some(iced::Background::Color(cosmic.background(false).base.into())), + background: Some(iced::Background::Color( + cosmic.background(false).base.into(), + )), border: iced::Border::default(), shadow: iced::Shadow::default(), snap: true, @@ -3611,6 +3954,7 @@ impl Application for App { &self.key_binds, popup_entity, link.clone(), + self.terminal_monitors(popup_pane, popup_entity), )) .position(widget::popover::Position::Point(point)); popover.into() @@ -3670,6 +4014,14 @@ impl Application for App { widget::tooltip::Position::Top, ) .into(), + widget::checkbox(self.find_case_sensitive) + .label(fl!("find-case-sensitive")) + .on_toggle(Message::FindCaseSensitive) + .into(), + widget::checkbox(self.find_regex) + .label(fl!("find-regex")) + .on_toggle(Message::FindRegex) + .into(), widget::space::horizontal().into(), button::custom(icon_cache_get("window-close-symbolic", 16)) .on_press(Message::Find(false)) @@ -3723,7 +4075,7 @@ impl Application for App { struct ConfigSubscription; struct TerminalEventSubscription; - Subscription::batch([ + let mut subscriptions = vec![ event::listen_with(|event, _status, _window_id| match event { Event::Keyboard(KeyEvent::KeyPressed { key, @@ -3776,7 +4128,16 @@ impl Application for App { Some(dialog) => dialog.subscription(), None => Subscription::none(), }, - ]) + ]; + + // Timer driving silence and process-finished monitors + if self.any_monitor_needs_tick() { + subscriptions.push( + iced::time::every(std::time::Duration::from_secs(1)).map(|_| Message::MonitorTick), + ); + } + + Subscription::batch(subscriptions) } } diff --git a/src/menu.rs b/src/menu.rs index 57c03ca..2c48fed 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -18,7 +18,7 @@ use cosmic::{ }; use std::{collections::HashMap, sync::LazyLock}; -use crate::{Action, ColorSchemeId, ColorSchemeKind, Config, Message, fl}; +use crate::{Action, ColorSchemeId, ColorSchemeKind, Config, Message, fl, terminal::MonitorKind}; static MENU_ID: LazyLock = LazyLock::new(|| cosmic::widget::Id::new("responsive-menu")); @@ -35,6 +35,7 @@ pub fn context_menu<'a>( key_binds: &HashMap, entity: segmented_button::Entity, link: Option, + monitors: (bool, bool, bool), ) -> Element<'a, Message> { let find_key = |action: &Action| -> String { for (key_bind, key_action) in key_binds { @@ -83,6 +84,23 @@ pub fn context_menu<'a>( Element::from(menu_item(fl!("select-all"), Action::SelectAll)), Element::from(divider::horizontal::light()), Element::from(menu_item(fl!("clear-scrollback"), Action::ClearScrollback)), + Element::from(menu_item(fl!("save-output"), Action::SaveOutput)), + Element::from(divider::horizontal::light()), + Element::from(menu_checkbox( + fl!("monitor-activity"), + monitors.0, + Action::Monitor(MonitorKind::Activity), + )), + Element::from(menu_checkbox( + fl!("monitor-silence"), + monitors.1, + Action::Monitor(MonitorKind::Silence), + )), + Element::from(menu_checkbox( + fl!("monitor-process-finished"), + monitors.2, + Action::Monitor(MonitorKind::ProcessFinished), + )), Element::from(divider::horizontal::light()), Element::from(menu_item( fl!("split-horizontal"), @@ -200,6 +218,7 @@ pub fn menu_bar<'a>( core: &Core, config: &Config, key_binds: &HashMap, + monitors: (bool, bool, bool), ) -> Element<'a, Message> { let mut profile_items = Vec::with_capacity(config.profiles.len()); for (name, id) in config.profile_names() { @@ -243,6 +262,7 @@ pub fn menu_bar<'a>( MenuItem::Button(fl!("select-all"), None, Action::SelectAll), MenuItem::Divider, MenuItem::Button(fl!("clear-scrollback"), None, Action::ClearScrollback), + MenuItem::Button(fl!("save-output"), None, Action::SaveOutput), MenuItem::Divider, MenuItem::Button(fl!("find"), None, Action::Find), ], @@ -269,6 +289,25 @@ pub fn menu_bar<'a>( Action::PaneToggleMaximized, ), MenuItem::Divider, + MenuItem::CheckBox( + fl!("monitor-activity"), + None, + monitors.0, + Action::Monitor(MonitorKind::Activity), + ), + MenuItem::CheckBox( + fl!("monitor-silence"), + None, + monitors.1, + Action::Monitor(MonitorKind::Silence), + ), + MenuItem::CheckBox( + fl!("monitor-process-finished"), + None, + monitors.2, + Action::Monitor(MonitorKind::ProcessFinished), + ), + MenuItem::Divider, MenuItem::Button( fl!("menu-color-schemes"), None, diff --git a/src/shortcuts.rs b/src/shortcuts.rs index e02ee00..a04a103 100644 --- a/src/shortcuts.rs +++ b/src/shortcuts.rs @@ -72,6 +72,7 @@ pub enum KeyBindAction { PastePrimary, #[cfg_attr(not(feature = "password_manager"), allow(dead_code))] PasswordManager, + SaveOutput, SelectAll, Settings, TabActivate0, @@ -113,6 +114,7 @@ impl KeyBindAction { Self::PaneToggleMaximized => Some(Action::PaneToggleMaximized), Self::Paste => Some(Action::Paste), Self::PastePrimary => Some(Action::PastePrimary), + Self::SaveOutput => Some(Action::SaveOutput), Self::SelectAll => Some(Action::SelectAll), Self::Settings => Some(Action::Settings), Self::TabActivate0 => Some(Action::TabActivate0), @@ -275,6 +277,7 @@ pub fn action_label(action: KeyBindAction) -> String { KeyBindAction::Paste => fl!("paste"), KeyBindAction::PastePrimary => fl!("paste-primary"), KeyBindAction::PasswordManager => fl!("password-manager"), + KeyBindAction::SaveOutput => fl!("save-output"), KeyBindAction::SelectAll => fl!("select-all"), KeyBindAction::Settings => fl!("settings"), KeyBindAction::TabActivate0 => fl!("tab-activate", number = 1), @@ -366,7 +369,7 @@ pub fn shortcut_groups() -> Vec { KeyBindAction::ZoomReset, ], }); - let mut other_actions = vec![KeyBindAction::ClearScrollback]; + let mut other_actions = vec![KeyBindAction::ClearScrollback, KeyBindAction::SaveOutput]; #[cfg(feature = "password_manager")] other_actions.push(KeyBindAction::PasswordManager); groups.push(ShortcutGroup { @@ -459,6 +462,7 @@ fn fallback_shortcuts() -> Shortcuts { bind!([Shift], "Insert", PastePrimary); bind!([Ctrl, Shift], "W", TabClose); bind!([Ctrl, Shift], "R", TabRename); + bind!([Ctrl, Shift], "S", SaveOutput); bind!([Ctrl], ",", Settings); bind!([], "F11", ToggleFullscreen); diff --git a/src/terminal.rs b/src/terminal.rs index 99ea621..1da3269 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -54,6 +54,17 @@ pub const MIN_CURSOR_CONTRAST: f64 = 1.5; /// A regex expression can start or end outside the visible screen. Therefore, without this constant, some regular expressions would not match at the top and bottom. pub const MAX_SEARCH_LINES: usize = 100; +/// Kinds of session monitoring, modeled after Konsole's monitor options. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MonitorKind { + Activity, + Silence, + ProcessFinished, +} + +/// Seconds without output before a silence alert fires (Konsole default). +pub const MONITOR_SILENCE_SECONDS: u64 = 10; + /// https://github.com/alacritty/alacritty/blob/4a7728bf7fac06a35f27f6c4f31e0d9214e5152b/alacritty/src/config/ui_config.rs#L36-L39 fn url_regex_search() -> RegexSearch { let url_regex = "(ipfs:|ipns:|magnet:|mailto:|gemini://|gopher://|https://|http://|news:|file:|git://|ssh:|ftp://)\ @@ -240,6 +251,12 @@ impl Metadata { pub struct Terminal { pub context_menu: Option, pub metadata_set: IndexSet, + pub monitor_activity: bool, + pub monitor_silence: bool, + pub monitor_process_finished: bool, + pub monitor_alert: Option, + pub monitor_last_output: Instant, + pub monitor_job_running: bool, pub needs_update: bool, pub profile_id_opt: Option, pub tab_title_override: Option, @@ -353,6 +370,12 @@ impl Terminal { default_attrs, dim_font_weight: Weight(dim_font_weight), metadata_set, + monitor_activity: false, + monitor_silence: false, + monitor_process_finished: false, + monitor_alert: None, + monitor_last_output: Instant::now(), + monitor_job_running: false, mouse_reporter: Default::default(), needs_update: true, notifier, @@ -620,6 +643,50 @@ impl Terminal { self.update(); } + /// Extract the entire scrollback plus visible screen as plain text, + /// trimming trailing empty lines. + pub fn scrollback_text(&self) -> String { + let term = self.term.lock(); + let grid = term.grid(); + let start = Point::new(Line(-(grid.history_size() as i32)), Column(0)); + let mut end_line = grid.bottommost_line(); + while end_line.0 > 0 { + if !grid[end_line].is_clear() { + break; + } + end_line.0 -= 1; + } + let end = Point::new(end_line, Column(grid.columns() - 1)); + term.bounds_to_string(start, end) + } + + pub fn monitoring_enabled(&self) -> bool { + self.monitor_activity || self.monitor_silence || self.monitor_process_finished + } + + /// Whether a foreground job (other than the shell itself) is running on + /// the terminal. Compares the process group of the shell with the + /// foreground process group of the controlling terminal. + pub fn foreground_job_running(&self) -> Option { + #[cfg(target_os = "linux")] + { + let shell_pid = self.shell_pid?; + let stat = fs::read_to_string(format!("/proc/{shell_pid}/stat")).ok()?; + // Fields after the last ')' to handle process names with spaces: + // state ppid pgrp session tty_nr tpgid ... + let after_comm = stat.rsplit_once(')')?.1; + let mut fields = after_comm.split_whitespace(); + let pgrp = fields.nth(2)?; + let tpgid = fields.nth(2)?; + Some(pgrp != tpgid) + } + + #[cfg(not(target_os = "linux"))] + { + None + } + } + pub fn select_all(&mut self) { { let mut term = self.term.lock();