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
This commit is contained in:
parent
5af952d285
commit
43d38aed8a
6 changed files with 529 additions and 42 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
441
src/main.rs
441
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<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs application with these settings
|
||||
#[rustfmt::skip]
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
|
|
@ -99,6 +126,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 +150,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 +183,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 +202,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 +281,7 @@ fn main() -> Result<(), Box<dyn Error>> {
|
|||
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 <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 +316,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 +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>), // 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),
|
||||
|
|
@ -400,6 +487,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,6 +516,8 @@ 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),
|
||||
|
|
@ -512,11 +603,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>)>,
|
||||
|
|
@ -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::<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
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
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::<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 +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::<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;
|
||||
}
|
||||
|
|
@ -2533,6 +2694,79 @@ impl Application for App {
|
|||
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 +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::<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());
|
||||
|
|
@ -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::<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 +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::<Mutex<Terminal>>(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::<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
|
||||
}
|
||||
|
|
@ -3483,7 +3813,12 @@ 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(),
|
||||
)]
|
||||
}
|
||||
|
||||
fn header_end(&self) -> Vec<Element<'_, Self::Message>> {
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
41
src/menu.rs
41
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<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,7 @@ pub fn menu_bar<'a>(
|
|||
core: &Core,
|
||||
config: &Config,
|
||||
key_binds: &HashMap<KeyBind, Action>,
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue