Compare commits

...

2 commits

Author SHA1 Message Date
d7cc5aaac1 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
2026-07-09 14:18:02 +02:00
43d38aed8a feat: Konsole parity phase 1 (save output, search options, monitors, profile CLI)
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/<pid>/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 <name-or-id> applies a profile to the first tab only,
  unknown profiles exit(1) with the available list on stderr.
- --list-profiles prints "id<TAB>name" 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
2026-07-06 09:50:58 +02:00
7 changed files with 968 additions and 57 deletions

View file

@ -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
@ -113,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
@ -123,6 +131,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

View file

@ -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
@ -83,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
@ -94,6 +102,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

147
src/layout.rs Normal file
View file

@ -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<pane_grid::Axis> 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<LayoutAxis> 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<u64>,
pub working_directory: Option<PathBuf>,
pub title: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub enum LayoutNode {
Split {
axis: LayoutAxis,
ratio: f32,
a: Box<LayoutNode>,
b: Box<LayoutNode>,
},
Pane {
tabs: Vec<LayoutTab>,
},
}
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<LayoutTab>> {
let mut leaves = Vec::new();
self.collect_leaves(&mut leaves);
leaves
}
fn collect_leaves<'a>(&'a self, leaves: &mut Vec<&'a Vec<LayoutTab>>) {
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, F: FnMut() -> T>(
&self,
new_model: &mut F,
) -> pane_grid::Configuration<T> {
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<_>>(),
vec![2, 1, 1]
);
}
}

View file

@ -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;
@ -65,7 +68,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 +92,66 @@ 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<segmented_button::SingleSelect>,
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);
}
}
}
/// Concrete host names from `~/.ssh/config` (Host entries without wildcards
/// or negations), sorted and deduplicated.
fn ssh_config_hosts() -> Vec<String> {
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<dyn Error>> {
@ -99,6 +162,8 @@ fn main() -> Result<(), Box<dyn Error>> {
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 +186,17 @@ fn main() -> Result<(), Box<dyn Error>> {
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 +219,8 @@ fn main() -> Result<(), Box<dyn Error>> {
}
}
// 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 +238,53 @@ fn main() -> Result<(), Box<dyn Error>> {
}
};
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::<u64>().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 +317,7 @@ fn main() -> Result<(), Box<dyn Error>> {
config,
shortcuts_config,
startup_options,
startup_profile_id,
term_config,
};
@ -226,7 +336,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 <dir> Set the working directory for the terminal"#
-w, --working-directory <dir> Set the working directory for the terminal
-p, --profile <name-or-id> 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 <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 +352,7 @@ pub struct Flags {
config: Config,
shortcuts_config: shortcuts::ShortcutsConfig,
startup_options: Option<tty::Options>,
startup_profile_id: Option<ProfileId>,
term_config: term::Config,
}
@ -251,6 +368,9 @@ pub enum Action {
Find,
KeyboardShortcuts,
LaunchUrlByMenu,
LayoutLoad,
LayoutSave,
Monitor(MonitorKind),
PaneFocusDown,
PaneFocusLeft,
PaneFocusRight,
@ -262,8 +382,10 @@ pub enum Action {
PastePrimary,
ProfileOpen(ProfileId),
Profiles,
SaveOutput,
SelectAll,
Settings,
SshConnect(usize),
#[cfg(feature = "password_manager")]
PasswordManager,
ShowHeaderBar(bool),
@ -305,6 +427,9 @@ 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),
Self::PaneFocusRight => Message::PaneFocusAdjacent(pane_grid::Direction::Right),
@ -318,8 +443,10 @@ 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::SshConnect(index) => Message::SshConnect(*index),
Self::ShowHeaderBar(show_headerbar) => Message::ShowHeaderBar(*show_headerbar),
Self::TabActivate0 => Message::TabActivateJump(0),
Self::TabActivate1 => Message::TabActivateJump(1),
@ -384,14 +511,22 @@ pub enum Message {
DialogMessage(Box<DialogMessage>), // 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<segmented_button::Entity>),
FocusFollowMouse(bool),
Key(Modifiers, Physical, Key),
LaunchUrl(String),
LaunchUrlByMenu,
LayoutLoad,
LayoutLoadApply,
LayoutLoadCancel,
LayoutLoadResult(DialogResult),
LayoutSave,
LayoutSaveResult(DialogResult),
Modifiers(Modifiers),
ShortcutCaptureCancel,
ShortcutCaptureStart(shortcuts::KeyBindAction),
@ -400,6 +535,8 @@ pub enum Message {
ShortcutRemove(shortcuts::Binding, shortcuts::BindingSource),
ShortcutReset(shortcuts::KeyBindAction),
ShortcutSearch(String),
MonitorTick,
MonitorToggle(MonitorKind, Option<segmented_button::Entity>),
MouseEnter(pane_grid::Pane),
Opacity(u8),
PaneClicked(pane_grid::Pane),
@ -427,10 +564,13 @@ pub enum Message {
ProfileSyntaxTheme(ProfileId, ColorSchemeKind, usize),
ProfileTabTitle(ProfileId, String),
ReorderTab(Pane, ReorderEvent),
SaveOutput(Option<segmented_button::Entity>),
SaveOutputResult(pane_grid::Pane, segmented_button::Entity, DialogResult),
Surface(surface::Action),
SelectAll(Option<segmented_button::Entity>),
ShowAdvancedFontSettings(bool),
ShowHeaderBar(bool),
SshConnect(usize),
SyntaxTheme(ColorSchemeKind, usize),
SystemThemeChange,
TabNewInheritWorkingDirectory(bool),
@ -512,11 +652,14 @@ pub struct App {
dialog_opt: Option<Dialog<Message>>,
terminal_ids: HashMap<pane_grid::Pane, widget::Id>,
find: bool,
find_case_sensitive: bool,
find_regex: bool,
find_search_id: widget::Id,
find_search_value: String,
term_event_tx_opt:
Option<mpsc::UnboundedSender<(pane_grid::Pane, segmented_button::Entity, TermEvent)>>,
startup_options: Option<tty::Options>,
startup_profile_id: Option<ProfileId>,
term_config: term::Config,
color_scheme_errors: Vec<String>,
color_scheme_expanded: Option<(ColorSchemeKind, Option<ColorSchemeId>)>,
@ -524,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<LayoutNode>,
ssh_hosts: Vec<String>,
color_scheme_tab_model: widget::segmented_button::SingleSelectModel,
profile_expanded: Option<ProfileId>,
show_advanced_font_settings: bool,
@ -1554,6 +1699,151 @@ 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::<Mutex<Terminal>>(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::<Mutex<Terminal>>(entity)
.is_some_and(|terminal| {
let terminal = terminal.lock().unwrap();
terminal.monitor_silence || terminal.monitor_process_finished
})
})
})
}
/// 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::<Mutex<Terminal>>(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<Message> {
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<pane_grid::Pane> = 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::<Mutex<Terminal>>(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,
@ -1887,9 +2177,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(),
@ -1898,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,
@ -2401,14 +2696,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::<Mutex<Terminal>>(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::<Mutex<Terminal>>(entity) {
let mut terminal = terminal.lock().unwrap();
terminal.search(&pattern, true);
}
}
}
@ -2416,19 +2716,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::<Mutex<Terminal>>(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::<Mutex<Terminal>>(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;
}
@ -2530,9 +2835,154 @@ 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::<LayoutNode>(&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;
}
Message::MonitorTick => {
let panes: Vec<pane_grid::Pane> = 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<segmented_button::Entity> = tab_model.iter().collect();
for entity in entities {
let mut icon_update = None;
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(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::<Mutex<Terminal>>(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 +3222,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::<Mutex<Terminal>>(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());
@ -2800,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::<Mutex<Terminal>>(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;
}
@ -2827,6 +3348,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::<Mutex<Terminal>>(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 +3540,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 +3738,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::<Mutex<Terminal>>(entity)
{
@ -3207,6 +3746,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::<Mutex<Terminal>>(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
}
@ -3430,6 +3996,21 @@ impl Application for App {
}
fn dialog(&self) -> Option<Element<'_, Message>> {
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())
@ -3483,7 +4064,13 @@ impl Application for App {
}
fn header_start(&self) -> Vec<Element<'_, Self::Message>> {
vec![menu_bar(&self.core, &self.config, &self.key_binds)]
vec![menu_bar(
&self.core,
&self.config,
&self.key_binds,
self.active_terminal_monitors(),
&self.ssh_hosts,
)]
}
fn header_end(&self) -> Vec<Element<'_, Self::Message>> {
@ -3506,12 +4093,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 +4139,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 +4206,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 +4266,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 +4327,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 +4380,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)
}
}

View file

@ -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<cosmic::widget::Id> =
LazyLock::new(|| cosmic::widget::Id::new("responsive-menu"));
@ -35,6 +35,7 @@ pub fn context_menu<'a>(
key_binds: &HashMap<KeyBind, Action>,
entity: segmented_button::Entity,
link: Option<String>,
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,8 @@ pub fn menu_bar<'a>(
core: &Core,
config: &Config,
key_binds: &HashMap<KeyBind, Action>,
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() {
@ -208,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()
@ -220,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![
@ -243,6 +275,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 +302,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,

View file

@ -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<ShortcutGroup> {
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);

View file

@ -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<MenuState>,
pub metadata_set: IndexSet<Metadata>,
pub monitor_activity: bool,
pub monitor_silence: bool,
pub monitor_process_finished: bool,
pub monitor_alert: Option<MonitorKind>,
pub monitor_last_output: Instant,
pub monitor_job_running: bool,
pub needs_update: bool,
pub profile_id_opt: Option<ProfileId>,
pub tab_title_override: Option<String>,
@ -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<bool> {
#[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();