From d7cc5aaac10810593c56424f391783295769e5de Mon Sep 17 00:00:00 2001 From: Lionel DARNIS Date: Thu, 9 Jul 2026 14:18:02 +0200 Subject: [PATCH] feat: Konsole parity phase 1b (layout save/load, SSH bookmarks) - Serialize pane grid splits and per-tab state to RON - Save/load layout via cosmic-files dialogs with confirmation - SSH submenu from ~/.ssh/config Host entries (no wildcards) - i18n en/fr for layout and SSH menu entries --- i18n/en/cosmic_term.ftl | 6 + i18n/fr/cosmic_term.ftl | 6 + src/layout.rs | 147 +++++++++++++++++++++++ src/main.rs | 252 ++++++++++++++++++++++++++++++++++++++++ src/menu.rs | 43 ++++--- 5 files changed, 439 insertions(+), 15 deletions(-) create mode 100644 src/layout.rs diff --git a/i18n/en/cosmic_term.ftl b/i18n/en/cosmic_term.ftl index 700085b..93d5e71 100644 --- a/i18n/en/cosmic_term.ftl +++ b/i18n/en/cosmic_term.ftl @@ -115,6 +115,12 @@ new-tab = New tab new-window = New window profile = Profile menu-profiles = Profiles... +ssh = SSH +save-layout = Save layout... +load-layout = Load layout... +load-layout-title = Load layout +load-layout-body = Loading a layout replaces all tabs and splits in this window. +load = Load close-tab = Close tab quit = Quit diff --git a/i18n/fr/cosmic_term.ftl b/i18n/fr/cosmic_term.ftl index 8fe64fc..992b18e 100644 --- a/i18n/fr/cosmic_term.ftl +++ b/i18n/fr/cosmic_term.ftl @@ -85,6 +85,12 @@ new-tab = Nouvel onglet new-window = Nouvelle fenĂȘtre profile = Profil menu-profiles = Profils... +ssh = SSH +save-layout = Enregistrer la disposition... +load-layout = Charger une disposition... +load-layout-title = Charger une disposition +load-layout-body = Charger une disposition remplace tous les onglets et divisions de cette fenĂȘtre. +load = Charger close-tab = Fermer l'onglet quit = Quitter diff --git a/src/layout.rs b/src/layout.rs new file mode 100644 index 0000000..68a5f57 --- /dev/null +++ b/src/layout.rs @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: GPL-3.0-only + +//! Serializable description of a window layout: the pane grid split tree +//! and, for each pane, the list of tabs to restore. + +use cosmic::widget::pane_grid; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub enum LayoutAxis { + Horizontal, + Vertical, +} + +impl From for LayoutAxis { + fn from(axis: pane_grid::Axis) -> Self { + match axis { + pane_grid::Axis::Horizontal => Self::Horizontal, + pane_grid::Axis::Vertical => Self::Vertical, + } + } +} + +impl From for pane_grid::Axis { + fn from(axis: LayoutAxis) -> Self { + match axis { + LayoutAxis::Horizontal => Self::Horizontal, + LayoutAxis::Vertical => Self::Vertical, + } + } +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct LayoutTab { + pub profile: Option, + pub working_directory: Option, + pub title: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub enum LayoutNode { + Split { + axis: LayoutAxis, + ratio: f32, + a: Box, + b: Box, + }, + Pane { + tabs: Vec, + }, +} + +impl LayoutNode { + /// Leaf tab lists in depth-first order (a before b), matching the pane + /// creation order of `pane_grid::State::with_configuration`. + pub fn leaves(&self) -> Vec<&Vec> { + let mut leaves = Vec::new(); + self.collect_leaves(&mut leaves); + leaves + } + + fn collect_leaves<'a>(&'a self, leaves: &mut Vec<&'a Vec>) { + match self { + Self::Split { a, b, .. } => { + a.collect_leaves(leaves); + b.collect_leaves(leaves); + } + Self::Pane { tabs } => leaves.push(tabs), + } + } + + /// Build a pane grid configuration with one empty tab model per leaf. + pub fn configuration T>( + &self, + new_model: &mut F, + ) -> pane_grid::Configuration { + match self { + Self::Split { axis, ratio, a, b } => pane_grid::Configuration::Split { + axis: (*axis).into(), + ratio: *ratio, + a: Box::new(a.configuration(new_model)), + b: Box::new(b.configuration(new_model)), + }, + Self::Pane { .. } => pane_grid::Configuration::Pane(new_model()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> LayoutNode { + LayoutNode::Split { + axis: LayoutAxis::Vertical, + ratio: 0.6, + a: Box::new(LayoutNode::Pane { + tabs: vec![ + LayoutTab { + profile: Some(1), + working_directory: Some(PathBuf::from("/tmp")), + title: Some("build".to_string()), + }, + LayoutTab::default(), + ], + }), + b: Box::new(LayoutNode::Split { + axis: LayoutAxis::Horizontal, + ratio: 0.5, + a: Box::new(LayoutNode::Pane { + tabs: vec![LayoutTab::default()], + }), + b: Box::new(LayoutNode::Pane { + tabs: vec![LayoutTab::default()], + }), + }), + } + } + + #[test] + fn ron_round_trip() { + let layout = sample(); + let ron = ron::ser::to_string_pretty(&layout, ron::ser::PrettyConfig::new()).unwrap(); + let parsed: LayoutNode = ron::from_str(&ron).unwrap(); + let leaves = parsed.leaves(); + assert_eq!(leaves.len(), 3); + assert_eq!(leaves[0].len(), 2); + assert_eq!(leaves[0][0].profile, Some(1)); + assert_eq!(leaves[0][0].title.as_deref(), Some("build")); + assert_eq!( + leaves[0][0].working_directory.as_deref(), + Some(std::path::Path::new("/tmp")) + ); + } + + #[test] + fn leaves_depth_first_order() { + let layout = sample(); + // Depth-first: pane a first (2 tabs), then the two nested panes + let leaves = layout.leaves(); + assert_eq!( + leaves.iter().map(|tabs| tabs.len()).collect::>(), + vec![2, 1, 1] + ); + } +} diff --git a/src/main.rs b/src/main.rs index 180eb18..4f13fe6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -58,6 +58,9 @@ mod icon_cache; use key_bind::key_binds; mod key_bind; +use layout::{LayoutNode, LayoutTab}; +mod layout; + mod shortcuts; mod localize; @@ -116,6 +119,39 @@ fn monitor_update_tab_icon( } } +/// Concrete host names from `~/.ssh/config` (Host entries without wildcards +/// or negations), sorted and deduplicated. +fn ssh_config_hosts() -> Vec { + let Some(home) = env::var_os("HOME") else { + return Vec::new(); + }; + let path = PathBuf::from(home).join(".ssh").join("config"); + let Ok(content) = fs::read_to_string(&path) else { + return Vec::new(); + }; + let mut hosts = BTreeSet::new(); + for line in content.lines() { + let line = line.trim(); + if line.starts_with('#') { + continue; + } + let mut parts = line.split_whitespace(); + let Some(keyword) = parts.next() else { + continue; + }; + if !keyword.eq_ignore_ascii_case("host") { + continue; + } + for token in parts { + if token.contains(['*', '?']) || token.starts_with('!') { + continue; + } + hosts.insert(token.to_string()); + } + } + hosts.into_iter().collect() +} + /// Runs application with these settings #[rustfmt::skip] fn main() -> Result<(), Box> { @@ -332,6 +368,8 @@ pub enum Action { Find, KeyboardShortcuts, LaunchUrlByMenu, + LayoutLoad, + LayoutSave, Monitor(MonitorKind), PaneFocusDown, PaneFocusLeft, @@ -347,6 +385,7 @@ pub enum Action { SaveOutput, SelectAll, Settings, + SshConnect(usize), #[cfg(feature = "password_manager")] PasswordManager, ShowHeaderBar(bool), @@ -388,6 +427,8 @@ impl Action { Self::Find => Message::Find(true), Self::KeyboardShortcuts => Message::ToggleContextPage(ContextPage::KeyboardShortcuts), Self::LaunchUrlByMenu => Message::LaunchUrlByMenu, + Self::LayoutLoad => Message::LayoutLoad, + Self::LayoutSave => Message::LayoutSave, Self::Monitor(monitor_kind) => Message::MonitorToggle(*monitor_kind, entity_opt), Self::PaneFocusDown => Message::PaneFocusAdjacent(pane_grid::Direction::Down), Self::PaneFocusLeft => Message::PaneFocusAdjacent(pane_grid::Direction::Left), @@ -405,6 +446,7 @@ impl Action { Self::SaveOutput => Message::SaveOutput(entity_opt), Self::SelectAll => Message::SelectAll(entity_opt), Self::Settings => Message::ToggleContextPage(ContextPage::Settings), + Self::SshConnect(index) => Message::SshConnect(*index), Self::ShowHeaderBar(show_headerbar) => Message::ShowHeaderBar(*show_headerbar), Self::TabActivate0 => Message::TabActivateJump(0), Self::TabActivate1 => Message::TabActivateJump(1), @@ -479,6 +521,12 @@ pub enum Message { Key(Modifiers, Physical, Key), LaunchUrl(String), LaunchUrlByMenu, + LayoutLoad, + LayoutLoadApply, + LayoutLoadCancel, + LayoutLoadResult(DialogResult), + LayoutSave, + LayoutSaveResult(DialogResult), Modifiers(Modifiers), ShortcutCaptureCancel, ShortcutCaptureStart(shortcuts::KeyBindAction), @@ -522,6 +570,7 @@ pub enum Message { SelectAll(Option), ShowAdvancedFontSettings(bool), ShowHeaderBar(bool), + SshConnect(usize), SyntaxTheme(ColorSchemeKind, usize), SystemThemeChange, TabNewInheritWorkingDirectory(bool), @@ -618,6 +667,8 @@ pub struct App { color_scheme_rename_id: widget::Id, rename_tab: Option<(segmented_button::Entity, String)>, rename_tab_id: widget::Id, + layout_load_pending: Option, + ssh_hosts: Vec, color_scheme_tab_model: widget::segmented_button::SingleSelectModel, profile_expanded: Option, show_advanced_font_settings: bool, @@ -1702,6 +1753,97 @@ impl App { }) } + /// Snapshot the current pane grid and tabs as a serializable layout. + fn layout_from_state(&self, node: &pane_grid::Node) -> LayoutNode { + match node { + pane_grid::Node::Split { + axis, ratio, a, b, .. + } => LayoutNode::Split { + axis: (*axis).into(), + ratio: *ratio, + a: Box::new(self.layout_from_state(a)), + b: Box::new(self.layout_from_state(b)), + }, + pane_grid::Node::Pane(pane) => { + let mut tabs = Vec::new(); + if let Some(tab_model) = self.pane_model.panes.get(*pane) { + for entity in tab_model.iter() { + if let Some(terminal) = tab_model.data::>(entity) { + let terminal = terminal.lock().unwrap(); + tabs.push(LayoutTab { + profile: terminal.profile_id_opt.map(|profile_id| profile_id.0), + working_directory: terminal.working_directory(), + title: terminal.tab_title_override.clone(), + }); + } + } + } + if tabs.is_empty() { + tabs.push(LayoutTab::default()); + } + LayoutNode::Pane { tabs } + } + } + } + + /// Replace the window content with the given layout: rebuild the pane + /// grid and spawn a terminal per saved tab. + fn layout_apply(&mut self, layout: &LayoutNode) -> Task { + self.pane_model.panes.restore(); + + // Rebuild the pane grid; leaf order matches `LayoutNode::leaves` + self.pane_model.panes = pane_grid::State::with_configuration( + layout.configuration(&mut || segmented_button::ModelBuilder::default().build()), + ); + self.terminal_ids.clear(); + let panes: Vec = self + .pane_model + .panes + .iter() + .map(|(pane, _)| *pane) + .collect(); + for pane in &panes { + self.terminal_ids.insert(*pane, widget::Id::unique()); + } + self.pane_model.panes_created = panes.len(); + + let mut tasks = Vec::new(); + let default_tabs = vec![LayoutTab::default()]; + for (pane, tabs) in panes.iter().zip(layout.leaves()) { + // Guard against hand-edited layouts with an empty tab list + let tabs = if tabs.is_empty() { &default_tabs } else { tabs }; + for tab in tabs { + let profile_id_opt = tab + .profile + .map(ProfileId) + .filter(|profile_id| self.config.profiles.contains_key(profile_id)); + self.startup_options = Some(tty::Options { + working_directory: tab.working_directory.clone(), + ..tty::Options::default() + }); + tasks.push(self.create_and_focus_new_terminal(*pane, profile_id_opt, false)); + + // Restore custom tab titles + if let Some(title) = &tab.title + && let Some(tab_model) = self.pane_model.panes.get_mut(*pane) + { + let entity = tab_model.active(); + tab_model.text_set(entity, title.clone()); + if let Some(terminal) = tab_model.data::>(entity) { + terminal.lock().unwrap().tab_title_override = Some(title.clone()); + } + } + } + } + + if let Some(first) = panes.first() { + self.pane_model.set_focus(*first); + } + tasks.push(self.update_title(None)); + tasks.push(self.update_focus()); + Task::batch(tasks) + } + fn create_and_focus_new_terminal( &mut self, pane: pane_grid::Pane, @@ -2049,6 +2191,8 @@ impl Application for App { color_scheme_rename_id: widget::Id::unique(), rename_tab: None, rename_tab_id: widget::Id::unique(), + layout_load_pending: None, + ssh_hosts: ssh_config_hosts(), color_scheme_tab_model: widget::segmented_button::Model::default(), profile_expanded: None, show_advanced_font_settings: false, @@ -2691,6 +2835,78 @@ impl Application for App { } } } + Message::LayoutLoad => { + if self.dialog_opt.is_none() { + let (dialog, command) = Dialog::new( + DialogSettings::new().kind(DialogKind::OpenFile), + |msg| Message::DialogMessage(Box::new(msg)), + Message::LayoutLoadResult, + ); + self.dialog_opt = Some(dialog); + return command; + } + } + Message::LayoutLoadApply => { + if let Some(layout) = self.layout_load_pending.take() { + return self.layout_apply(&layout); + } + } + Message::LayoutLoadCancel => { + self.layout_load_pending = None; + self.pane_model.update_terminal_focus(); + return self.update_focus(); + } + Message::LayoutLoadResult(result) => { + self.dialog_opt = None; + if let DialogResult::Open(paths) = result { + let path = &paths[0]; + match fs::read_to_string(path) { + Ok(content) => match ron::from_str::(&content) { + Ok(layout) => { + // Ask for confirmation before replacing sessions + self.layout_load_pending = Some(layout); + self.pane_model.unfocus_all_terminals(); + } + Err(err) => { + log::error!("failed to parse layout {:?}: {}", path, err); + } + }, + Err(err) => { + log::error!("failed to read layout {:?}: {}", path, err); + } + } + } + } + Message::LayoutSave => { + if self.dialog_opt.is_none() { + let (dialog, command) = Dialog::new( + DialogSettings::new().kind(DialogKind::SaveFile { + filename: String::from("cosmic-term-layout.ron"), + }), + |msg| Message::DialogMessage(Box::new(msg)), + Message::LayoutSaveResult, + ); + self.dialog_opt = Some(dialog); + return command; + } + } + Message::LayoutSaveResult(result) => { + self.dialog_opt = None; + if let DialogResult::Open(paths) = result { + let path = &paths[0]; + let layout = self.layout_from_state(self.pane_model.panes.layout()); + match ron::ser::to_string_pretty(&layout, ron::ser::PrettyConfig::new()) { + Ok(ron) => { + if let Err(err) = fs::write(path, ron) { + log::error!("failed to save layout to {:?}: {}", path, err); + } + } + Err(err) => { + log::error!("failed to serialize layout: {}", err); + } + } + } + } Message::Modifiers(modifiers) => { self.modifiers = modifiers; } @@ -3085,6 +3301,26 @@ impl Application for App { return self.update_config(); } } + Message::SshConnect(index) => { + if let Some(host) = self.ssh_hosts.get(index).cloned() { + self.startup_options = Some(tty::Options { + shell: Some(tty::Shell::new("ssh".to_string(), vec![host.clone()])), + ..tty::Options::default() + }); + let task = + self.create_and_focus_new_terminal(self.pane_model.focused(), None, false); + + // Name the tab after the host + if let Some(tab_model) = self.pane_model.active_mut() { + let entity = tab_model.active(); + tab_model.text_set(entity, host.clone()); + if let Some(terminal) = tab_model.data::>(entity) { + terminal.lock().unwrap().tab_title_override = Some(host); + } + } + return Task::batch([task, self.update_title(None)]); + } + } Message::ShowAdvancedFontSettings(show) => { self.show_advanced_font_settings = show; } @@ -3760,6 +3996,21 @@ impl Application for App { } fn dialog(&self) -> Option> { + if self.layout_load_pending.is_some() { + return Some( + widget::dialog() + .title(fl!("load-layout-title")) + .body(fl!("load-layout-body")) + .primary_action( + widget::button::destructive(fl!("load")).on_press(Message::LayoutLoadApply), + ) + .secondary_action( + widget::button::standard(fl!("cancel")).on_press(Message::LayoutLoadCancel), + ) + .into(), + ); + } + if let Some((_, buffer)) = self.rename_tab.as_ref() { let input = widget::text_input(fl!("tab-name-placeholder"), buffer) .id(self.rename_tab_id.clone()) @@ -3818,6 +4069,7 @@ impl Application for App { &self.config, &self.key_binds, self.active_terminal_monitors(), + &self.ssh_hosts, )] } diff --git a/src/menu.rs b/src/menu.rs index 2c48fed..4473f93 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -219,6 +219,7 @@ pub fn menu_bar<'a>( config: &Config, key_binds: &HashMap, monitors: (bool, bool, bool), + ssh_hosts: &[String], ) -> Element<'a, Message> { let mut profile_items = Vec::with_capacity(config.profiles.len()); for (name, id) in config.profile_names() { @@ -227,6 +228,32 @@ pub fn menu_bar<'a>( //TODO: what to do if there are no profiles? + let mut file_items = vec![ + MenuItem::Button(fl!("new-tab"), None, Action::TabNew), + MenuItem::Button(fl!("new-window"), None, Action::WindowNew), + MenuItem::Divider, + MenuItem::Folder(fl!("profile"), profile_items), + MenuItem::Button(fl!("menu-profiles"), None, Action::Profiles), + MenuItem::Divider, + ]; + if !ssh_hosts.is_empty() { + let ssh_items = ssh_hosts + .iter() + .enumerate() + .map(|(index, host)| MenuItem::Button(host.clone(), None, Action::SshConnect(index))) + .collect(); + file_items.push(MenuItem::Folder(fl!("ssh"), ssh_items)); + } + file_items.extend([ + MenuItem::Button(fl!("save-layout"), None, Action::LayoutSave), + MenuItem::Button(fl!("load-layout"), None, Action::LayoutLoad), + MenuItem::Divider, + MenuItem::Button(fl!("rename-tab"), None, Action::TabRename), + MenuItem::Button(fl!("close-tab"), None, Action::TabClose), + MenuItem::Divider, + MenuItem::Button(fl!("quit"), None, Action::WindowClose), + ]); + let color_scheme_kind = config.color_scheme_kind(core.system_theme()); responsive_menu_bar() @@ -239,21 +266,7 @@ pub fn menu_bar<'a>( MENU_ID.clone(), Message::Surface, vec![ - ( - fl!("file"), - vec![ - MenuItem::Button(fl!("new-tab"), None, Action::TabNew), - MenuItem::Button(fl!("new-window"), None, Action::WindowNew), - MenuItem::Divider, - MenuItem::Folder(fl!("profile"), profile_items), - MenuItem::Button(fl!("menu-profiles"), None, Action::Profiles), - MenuItem::Divider, - MenuItem::Button(fl!("rename-tab"), None, Action::TabRename), - MenuItem::Button(fl!("close-tab"), None, Action::TabClose), - MenuItem::Divider, - MenuItem::Button(fl!("quit"), None, Action::WindowClose), - ], - ), + (fl!("file"), file_items), ( fl!("edit"), vec![