From 43d38aed8a230d8800f35ca33f56b3c6ae0f92a5 Mon Sep 17 00:00:00 2001 From: Lionel DARNIS Date: Mon, 6 Jul 2026 09:50:58 +0200 Subject: [PATCH 1/2] feat: Konsole parity phase 1 (save output, search options, monitors, profile CLI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the first batch of COSMIC_TERMINAL_KONSOLE_PARITY.md: Save output as text: - New SaveOutput action in the Edit menu, terminal context menu and keyboard shortcuts (Ctrl+Shift+S, Konsole parity, rebindable). - Terminal::scrollback_text() extracts history plus visible screen via alacritty bounds_to_string, trimming trailing empty lines. - Save-file dialog through cosmic-files; the file is written in a spawn_blocking task so large scrollbacks never stall the UI. Search options: - Case-sensitive and regex checkboxes in the find bar. - App::find_pattern() escapes the pattern when regex mode is off and prefixes (?i) when case-insensitive (same approach as Alacritty). Per-tab monitors (activity / silence / process finished): - Toggles in the View menu and terminal context menu, state stored in Terminal (monitor_* fields). - Activity alerts on PTY output (Wakeup) for non-active tabs; silence alerts after 10 s without output (Konsole default); process-finished compares the shell pgrp with the tty tpgid via /proc//stat and alerts when the foreground job exits. - Silence and process checks run on a 1 s iced::time subscription that is only active while at least one tab needs it. - Tab bar shows an armed icon (view-reveal-symbolic) and per-kind alert icons; activating the tab acknowledges the alert. Profile CLI: - --profile/-p applies a profile to the first tab only, unknown profiles exit(1) with the available list on stderr. - --list-profiles prints "idname" and exits. - Config is now loaded before the daemonize fork so CLI output reaches the launching terminal. - --help now documents -e/--command, --no-daemon and the new flags (-e already worked; audit confirmed everything after it becomes the command and its arguments). i18n: new en/fr strings (save-output, monitor-*, find-case-sensitive, find-regex). Validated with ./check_cosmic_local.sh terminal, cargo fmt, and a debug build exercising the CLI flags and a 6 s live run. The shutdown panic in iced_winit ("async fn resumed after completion") pre-exists and is reproducible with the installed binary. Leyoda 2026 – GPLv3 --- i18n/en/cosmic_term.ftl | 8 + i18n/fr/cosmic_term.ftl | 8 + src/main.rs | 441 ++++++++++++++++++++++++++++++++++++---- src/menu.rs | 41 +++- src/shortcuts.rs | 6 +- src/terminal.rs | 67 ++++++ 6 files changed, 529 insertions(+), 42 deletions(-) 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(); From d7cc5aaac10810593c56424f391783295769e5de Mon Sep 17 00:00:00 2001 From: Lionel DARNIS Date: Thu, 9 Jul 2026 14:18:02 +0200 Subject: [PATCH 2/2] feat: Konsole parity phase 1b (layout save/load, SSH bookmarks) - Serialize pane grid splits and per-tab state to RON - Save/load layout via cosmic-files dialogs with confirmation - SSH submenu from ~/.ssh/config Host entries (no wildcards) - i18n en/fr for layout and SSH menu entries --- i18n/en/cosmic_term.ftl | 6 + i18n/fr/cosmic_term.ftl | 6 + src/layout.rs | 147 +++++++++++++++++++++++ src/main.rs | 252 ++++++++++++++++++++++++++++++++++++++++ src/menu.rs | 43 ++++--- 5 files changed, 439 insertions(+), 15 deletions(-) create mode 100644 src/layout.rs diff --git a/i18n/en/cosmic_term.ftl b/i18n/en/cosmic_term.ftl index 700085b..93d5e71 100644 --- a/i18n/en/cosmic_term.ftl +++ b/i18n/en/cosmic_term.ftl @@ -115,6 +115,12 @@ new-tab = New tab new-window = New window profile = Profile menu-profiles = Profiles... +ssh = SSH +save-layout = Save layout... +load-layout = Load layout... +load-layout-title = Load layout +load-layout-body = Loading a layout replaces all tabs and splits in this window. +load = Load close-tab = Close tab quit = Quit diff --git a/i18n/fr/cosmic_term.ftl b/i18n/fr/cosmic_term.ftl index 8fe64fc..992b18e 100644 --- a/i18n/fr/cosmic_term.ftl +++ b/i18n/fr/cosmic_term.ftl @@ -85,6 +85,12 @@ new-tab = Nouvel onglet new-window = Nouvelle fenêtre profile = Profil menu-profiles = Profils... +ssh = SSH +save-layout = Enregistrer la disposition... +load-layout = Charger une disposition... +load-layout-title = Charger une disposition +load-layout-body = Charger une disposition remplace tous les onglets et divisions de cette fenêtre. +load = Charger close-tab = Fermer l'onglet quit = Quitter diff --git a/src/layout.rs b/src/layout.rs new file mode 100644 index 0000000..68a5f57 --- /dev/null +++ b/src/layout.rs @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: GPL-3.0-only + +//! Serializable description of a window layout: the pane grid split tree +//! and, for each pane, the list of tabs to restore. + +use cosmic::widget::pane_grid; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub enum LayoutAxis { + Horizontal, + Vertical, +} + +impl From for LayoutAxis { + fn from(axis: pane_grid::Axis) -> Self { + match axis { + pane_grid::Axis::Horizontal => Self::Horizontal, + pane_grid::Axis::Vertical => Self::Vertical, + } + } +} + +impl From for pane_grid::Axis { + fn from(axis: LayoutAxis) -> Self { + match axis { + LayoutAxis::Horizontal => Self::Horizontal, + LayoutAxis::Vertical => Self::Vertical, + } + } +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct LayoutTab { + pub profile: Option, + pub working_directory: Option, + pub title: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub enum LayoutNode { + Split { + axis: LayoutAxis, + ratio: f32, + a: Box, + b: Box, + }, + Pane { + tabs: Vec, + }, +} + +impl LayoutNode { + /// Leaf tab lists in depth-first order (a before b), matching the pane + /// creation order of `pane_grid::State::with_configuration`. + pub fn leaves(&self) -> Vec<&Vec> { + let mut leaves = Vec::new(); + self.collect_leaves(&mut leaves); + leaves + } + + fn collect_leaves<'a>(&'a self, leaves: &mut Vec<&'a Vec>) { + match self { + Self::Split { a, b, .. } => { + a.collect_leaves(leaves); + b.collect_leaves(leaves); + } + Self::Pane { tabs } => leaves.push(tabs), + } + } + + /// Build a pane grid configuration with one empty tab model per leaf. + pub fn configuration T>( + &self, + new_model: &mut F, + ) -> pane_grid::Configuration { + match self { + Self::Split { axis, ratio, a, b } => pane_grid::Configuration::Split { + axis: (*axis).into(), + ratio: *ratio, + a: Box::new(a.configuration(new_model)), + b: Box::new(b.configuration(new_model)), + }, + Self::Pane { .. } => pane_grid::Configuration::Pane(new_model()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> LayoutNode { + LayoutNode::Split { + axis: LayoutAxis::Vertical, + ratio: 0.6, + a: Box::new(LayoutNode::Pane { + tabs: vec![ + LayoutTab { + profile: Some(1), + working_directory: Some(PathBuf::from("/tmp")), + title: Some("build".to_string()), + }, + LayoutTab::default(), + ], + }), + b: Box::new(LayoutNode::Split { + axis: LayoutAxis::Horizontal, + ratio: 0.5, + a: Box::new(LayoutNode::Pane { + tabs: vec![LayoutTab::default()], + }), + b: Box::new(LayoutNode::Pane { + tabs: vec![LayoutTab::default()], + }), + }), + } + } + + #[test] + fn ron_round_trip() { + let layout = sample(); + let ron = ron::ser::to_string_pretty(&layout, ron::ser::PrettyConfig::new()).unwrap(); + let parsed: LayoutNode = ron::from_str(&ron).unwrap(); + let leaves = parsed.leaves(); + assert_eq!(leaves.len(), 3); + assert_eq!(leaves[0].len(), 2); + assert_eq!(leaves[0][0].profile, Some(1)); + assert_eq!(leaves[0][0].title.as_deref(), Some("build")); + assert_eq!( + leaves[0][0].working_directory.as_deref(), + Some(std::path::Path::new("/tmp")) + ); + } + + #[test] + fn leaves_depth_first_order() { + let layout = sample(); + // Depth-first: pane a first (2 tabs), then the two nested panes + let leaves = layout.leaves(); + assert_eq!( + leaves.iter().map(|tabs| tabs.len()).collect::>(), + vec![2, 1, 1] + ); + } +} diff --git a/src/main.rs b/src/main.rs index 180eb18..4f13fe6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -58,6 +58,9 @@ mod icon_cache; use key_bind::key_binds; mod key_bind; +use layout::{LayoutNode, LayoutTab}; +mod layout; + mod shortcuts; mod localize; @@ -116,6 +119,39 @@ fn monitor_update_tab_icon( } } +/// Concrete host names from `~/.ssh/config` (Host entries without wildcards +/// or negations), sorted and deduplicated. +fn ssh_config_hosts() -> Vec { + let Some(home) = env::var_os("HOME") else { + return Vec::new(); + }; + let path = PathBuf::from(home).join(".ssh").join("config"); + let Ok(content) = fs::read_to_string(&path) else { + return Vec::new(); + }; + let mut hosts = BTreeSet::new(); + for line in content.lines() { + let line = line.trim(); + if line.starts_with('#') { + continue; + } + let mut parts = line.split_whitespace(); + let Some(keyword) = parts.next() else { + continue; + }; + if !keyword.eq_ignore_ascii_case("host") { + continue; + } + for token in parts { + if token.contains(['*', '?']) || token.starts_with('!') { + continue; + } + hosts.insert(token.to_string()); + } + } + hosts.into_iter().collect() +} + /// Runs application with these settings #[rustfmt::skip] fn main() -> Result<(), Box> { @@ -332,6 +368,8 @@ pub enum Action { Find, KeyboardShortcuts, LaunchUrlByMenu, + LayoutLoad, + LayoutSave, Monitor(MonitorKind), PaneFocusDown, PaneFocusLeft, @@ -347,6 +385,7 @@ pub enum Action { SaveOutput, SelectAll, Settings, + SshConnect(usize), #[cfg(feature = "password_manager")] PasswordManager, ShowHeaderBar(bool), @@ -388,6 +427,8 @@ impl Action { Self::Find => Message::Find(true), Self::KeyboardShortcuts => Message::ToggleContextPage(ContextPage::KeyboardShortcuts), Self::LaunchUrlByMenu => Message::LaunchUrlByMenu, + Self::LayoutLoad => Message::LayoutLoad, + Self::LayoutSave => Message::LayoutSave, 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), @@ -405,6 +446,7 @@ impl Action { Self::SaveOutput => Message::SaveOutput(entity_opt), Self::SelectAll => Message::SelectAll(entity_opt), Self::Settings => Message::ToggleContextPage(ContextPage::Settings), + Self::SshConnect(index) => Message::SshConnect(*index), Self::ShowHeaderBar(show_headerbar) => Message::ShowHeaderBar(*show_headerbar), Self::TabActivate0 => Message::TabActivateJump(0), Self::TabActivate1 => Message::TabActivateJump(1), @@ -479,6 +521,12 @@ pub enum Message { Key(Modifiers, Physical, Key), LaunchUrl(String), LaunchUrlByMenu, + LayoutLoad, + LayoutLoadApply, + LayoutLoadCancel, + LayoutLoadResult(DialogResult), + LayoutSave, + LayoutSaveResult(DialogResult), Modifiers(Modifiers), ShortcutCaptureCancel, ShortcutCaptureStart(shortcuts::KeyBindAction), @@ -522,6 +570,7 @@ pub enum Message { SelectAll(Option), ShowAdvancedFontSettings(bool), ShowHeaderBar(bool), + SshConnect(usize), SyntaxTheme(ColorSchemeKind, usize), SystemThemeChange, TabNewInheritWorkingDirectory(bool), @@ -618,6 +667,8 @@ pub struct App { color_scheme_rename_id: widget::Id, rename_tab: Option<(segmented_button::Entity, String)>, rename_tab_id: widget::Id, + layout_load_pending: Option, + ssh_hosts: Vec, color_scheme_tab_model: widget::segmented_button::SingleSelectModel, profile_expanded: Option, show_advanced_font_settings: bool, @@ -1702,6 +1753,97 @@ impl App { }) } + /// Snapshot the current pane grid and tabs as a serializable layout. + fn layout_from_state(&self, node: &pane_grid::Node) -> LayoutNode { + match node { + pane_grid::Node::Split { + axis, ratio, a, b, .. + } => LayoutNode::Split { + axis: (*axis).into(), + ratio: *ratio, + a: Box::new(self.layout_from_state(a)), + b: Box::new(self.layout_from_state(b)), + }, + pane_grid::Node::Pane(pane) => { + let mut tabs = Vec::new(); + if let Some(tab_model) = self.pane_model.panes.get(*pane) { + for entity in tab_model.iter() { + if let Some(terminal) = tab_model.data::>(entity) { + let terminal = terminal.lock().unwrap(); + tabs.push(LayoutTab { + profile: terminal.profile_id_opt.map(|profile_id| profile_id.0), + working_directory: terminal.working_directory(), + title: terminal.tab_title_override.clone(), + }); + } + } + } + if tabs.is_empty() { + tabs.push(LayoutTab::default()); + } + LayoutNode::Pane { tabs } + } + } + } + + /// Replace the window content with the given layout: rebuild the pane + /// grid and spawn a terminal per saved tab. + fn layout_apply(&mut self, layout: &LayoutNode) -> Task { + self.pane_model.panes.restore(); + + // Rebuild the pane grid; leaf order matches `LayoutNode::leaves` + self.pane_model.panes = pane_grid::State::with_configuration( + layout.configuration(&mut || segmented_button::ModelBuilder::default().build()), + ); + self.terminal_ids.clear(); + let panes: Vec = self + .pane_model + .panes + .iter() + .map(|(pane, _)| *pane) + .collect(); + for pane in &panes { + self.terminal_ids.insert(*pane, widget::Id::unique()); + } + self.pane_model.panes_created = panes.len(); + + let mut tasks = Vec::new(); + let default_tabs = vec![LayoutTab::default()]; + for (pane, tabs) in panes.iter().zip(layout.leaves()) { + // Guard against hand-edited layouts with an empty tab list + let tabs = if tabs.is_empty() { &default_tabs } else { tabs }; + for tab in tabs { + let profile_id_opt = tab + .profile + .map(ProfileId) + .filter(|profile_id| self.config.profiles.contains_key(profile_id)); + self.startup_options = Some(tty::Options { + working_directory: tab.working_directory.clone(), + ..tty::Options::default() + }); + tasks.push(self.create_and_focus_new_terminal(*pane, profile_id_opt, false)); + + // Restore custom tab titles + if let Some(title) = &tab.title + && let Some(tab_model) = self.pane_model.panes.get_mut(*pane) + { + let entity = tab_model.active(); + tab_model.text_set(entity, title.clone()); + if let Some(terminal) = tab_model.data::>(entity) { + terminal.lock().unwrap().tab_title_override = Some(title.clone()); + } + } + } + } + + if let Some(first) = panes.first() { + self.pane_model.set_focus(*first); + } + tasks.push(self.update_title(None)); + tasks.push(self.update_focus()); + Task::batch(tasks) + } + fn create_and_focus_new_terminal( &mut self, pane: pane_grid::Pane, @@ -2049,6 +2191,8 @@ impl Application for App { color_scheme_rename_id: widget::Id::unique(), rename_tab: None, rename_tab_id: widget::Id::unique(), + layout_load_pending: None, + ssh_hosts: ssh_config_hosts(), color_scheme_tab_model: widget::segmented_button::Model::default(), profile_expanded: None, show_advanced_font_settings: false, @@ -2691,6 +2835,78 @@ impl Application for App { } } } + Message::LayoutLoad => { + if self.dialog_opt.is_none() { + let (dialog, command) = Dialog::new( + DialogSettings::new().kind(DialogKind::OpenFile), + |msg| Message::DialogMessage(Box::new(msg)), + Message::LayoutLoadResult, + ); + self.dialog_opt = Some(dialog); + return command; + } + } + Message::LayoutLoadApply => { + if let Some(layout) = self.layout_load_pending.take() { + return self.layout_apply(&layout); + } + } + Message::LayoutLoadCancel => { + self.layout_load_pending = None; + self.pane_model.update_terminal_focus(); + return self.update_focus(); + } + Message::LayoutLoadResult(result) => { + self.dialog_opt = None; + if let DialogResult::Open(paths) = result { + let path = &paths[0]; + match fs::read_to_string(path) { + Ok(content) => match ron::from_str::(&content) { + Ok(layout) => { + // Ask for confirmation before replacing sessions + self.layout_load_pending = Some(layout); + self.pane_model.unfocus_all_terminals(); + } + Err(err) => { + log::error!("failed to parse layout {:?}: {}", path, err); + } + }, + Err(err) => { + log::error!("failed to read layout {:?}: {}", path, err); + } + } + } + } + Message::LayoutSave => { + if self.dialog_opt.is_none() { + let (dialog, command) = Dialog::new( + DialogSettings::new().kind(DialogKind::SaveFile { + filename: String::from("cosmic-term-layout.ron"), + }), + |msg| Message::DialogMessage(Box::new(msg)), + Message::LayoutSaveResult, + ); + self.dialog_opt = Some(dialog); + return command; + } + } + Message::LayoutSaveResult(result) => { + self.dialog_opt = None; + if let DialogResult::Open(paths) = result { + let path = &paths[0]; + let layout = self.layout_from_state(self.pane_model.panes.layout()); + match ron::ser::to_string_pretty(&layout, ron::ser::PrettyConfig::new()) { + Ok(ron) => { + if let Err(err) = fs::write(path, ron) { + log::error!("failed to save layout to {:?}: {}", path, err); + } + } + Err(err) => { + log::error!("failed to serialize layout: {}", err); + } + } + } + } Message::Modifiers(modifiers) => { self.modifiers = modifiers; } @@ -3085,6 +3301,26 @@ impl Application for App { return self.update_config(); } } + Message::SshConnect(index) => { + if let Some(host) = self.ssh_hosts.get(index).cloned() { + self.startup_options = Some(tty::Options { + shell: Some(tty::Shell::new("ssh".to_string(), vec![host.clone()])), + ..tty::Options::default() + }); + let task = + self.create_and_focus_new_terminal(self.pane_model.focused(), None, false); + + // Name the tab after the host + if let Some(tab_model) = self.pane_model.active_mut() { + let entity = tab_model.active(); + tab_model.text_set(entity, host.clone()); + if let Some(terminal) = tab_model.data::>(entity) { + terminal.lock().unwrap().tab_title_override = Some(host); + } + } + return Task::batch([task, self.update_title(None)]); + } + } Message::ShowAdvancedFontSettings(show) => { self.show_advanced_font_settings = show; } @@ -3760,6 +3996,21 @@ impl Application for App { } fn dialog(&self) -> Option> { + if self.layout_load_pending.is_some() { + return Some( + widget::dialog() + .title(fl!("load-layout-title")) + .body(fl!("load-layout-body")) + .primary_action( + widget::button::destructive(fl!("load")).on_press(Message::LayoutLoadApply), + ) + .secondary_action( + widget::button::standard(fl!("cancel")).on_press(Message::LayoutLoadCancel), + ) + .into(), + ); + } + if let Some((_, buffer)) = self.rename_tab.as_ref() { let input = widget::text_input(fl!("tab-name-placeholder"), buffer) .id(self.rename_tab_id.clone()) @@ -3818,6 +4069,7 @@ impl Application for App { &self.config, &self.key_binds, self.active_terminal_monitors(), + &self.ssh_hosts, )] } diff --git a/src/menu.rs b/src/menu.rs index 2c48fed..4473f93 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -219,6 +219,7 @@ pub fn menu_bar<'a>( config: &Config, key_binds: &HashMap, monitors: (bool, bool, bool), + ssh_hosts: &[String], ) -> Element<'a, Message> { let mut profile_items = Vec::with_capacity(config.profiles.len()); for (name, id) in config.profile_names() { @@ -227,6 +228,32 @@ pub fn menu_bar<'a>( //TODO: what to do if there are no profiles? + let mut file_items = vec![ + MenuItem::Button(fl!("new-tab"), None, Action::TabNew), + MenuItem::Button(fl!("new-window"), None, Action::WindowNew), + MenuItem::Divider, + MenuItem::Folder(fl!("profile"), profile_items), + MenuItem::Button(fl!("menu-profiles"), None, Action::Profiles), + MenuItem::Divider, + ]; + if !ssh_hosts.is_empty() { + let ssh_items = ssh_hosts + .iter() + .enumerate() + .map(|(index, host)| MenuItem::Button(host.clone(), None, Action::SshConnect(index))) + .collect(); + file_items.push(MenuItem::Folder(fl!("ssh"), ssh_items)); + } + file_items.extend([ + MenuItem::Button(fl!("save-layout"), None, Action::LayoutSave), + MenuItem::Button(fl!("load-layout"), None, Action::LayoutLoad), + MenuItem::Divider, + MenuItem::Button(fl!("rename-tab"), None, Action::TabRename), + MenuItem::Button(fl!("close-tab"), None, Action::TabClose), + MenuItem::Divider, + MenuItem::Button(fl!("quit"), None, Action::WindowClose), + ]); + let color_scheme_kind = config.color_scheme_kind(core.system_theme()); responsive_menu_bar() @@ -239,21 +266,7 @@ pub fn menu_bar<'a>( MENU_ID.clone(), Message::Surface, vec![ - ( - fl!("file"), - vec![ - MenuItem::Button(fl!("new-tab"), None, Action::TabNew), - MenuItem::Button(fl!("new-window"), None, Action::WindowNew), - MenuItem::Divider, - MenuItem::Folder(fl!("profile"), profile_items), - MenuItem::Button(fl!("menu-profiles"), None, Action::Profiles), - MenuItem::Divider, - MenuItem::Button(fl!("rename-tab"), None, Action::TabRename), - MenuItem::Button(fl!("close-tab"), None, Action::TabClose), - MenuItem::Divider, - MenuItem::Button(fl!("quit"), None, Action::WindowClose), - ], - ), + (fl!("file"), file_items), ( fl!("edit"), vec![