Compare commits
2 commits
5af952d285
...
d7cc5aaac1
| Author | SHA1 | Date | |
|---|---|---|---|
| d7cc5aaac1 | |||
| 43d38aed8a |
7 changed files with 968 additions and 57 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
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
147
src/layout.rs
Normal 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]
|
||||
);
|
||||
}
|
||||
}
|
||||
693
src/main.rs
693
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;
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
84
src/menu.rs
84
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,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,
|
||||
|
|
|
|||
|
|
@ -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