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
This commit is contained in:
Lionel DARNIS 2026-07-09 14:18:02 +02:00
parent 43d38aed8a
commit d7cc5aaac1
5 changed files with 439 additions and 15 deletions

View file

@ -115,6 +115,12 @@ new-tab = New tab
new-window = New window new-window = New window
profile = Profile profile = Profile
menu-profiles = Profiles... 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 close-tab = Close tab
quit = Quit quit = Quit

View file

@ -85,6 +85,12 @@ new-tab = Nouvel onglet
new-window = Nouvelle fenêtre new-window = Nouvelle fenêtre
profile = Profil profile = Profil
menu-profiles = Profils... 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 close-tab = Fermer l'onglet
quit = Quitter quit = Quitter

147
src/layout.rs Normal file
View file

@ -0,0 +1,147 @@
// SPDX-License-Identifier: GPL-3.0-only
//! Serializable description of a window layout: the pane grid split tree
//! and, for each pane, the list of tabs to restore.
use cosmic::widget::pane_grid;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum LayoutAxis {
Horizontal,
Vertical,
}
impl From<pane_grid::Axis> for LayoutAxis {
fn from(axis: pane_grid::Axis) -> Self {
match axis {
pane_grid::Axis::Horizontal => Self::Horizontal,
pane_grid::Axis::Vertical => Self::Vertical,
}
}
}
impl From<LayoutAxis> for pane_grid::Axis {
fn from(axis: LayoutAxis) -> Self {
match axis {
LayoutAxis::Horizontal => Self::Horizontal,
LayoutAxis::Vertical => Self::Vertical,
}
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct LayoutTab {
pub profile: Option<u64>,
pub working_directory: Option<PathBuf>,
pub title: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub enum LayoutNode {
Split {
axis: LayoutAxis,
ratio: f32,
a: Box<LayoutNode>,
b: Box<LayoutNode>,
},
Pane {
tabs: Vec<LayoutTab>,
},
}
impl LayoutNode {
/// Leaf tab lists in depth-first order (a before b), matching the pane
/// creation order of `pane_grid::State::with_configuration`.
pub fn leaves(&self) -> Vec<&Vec<LayoutTab>> {
let mut leaves = Vec::new();
self.collect_leaves(&mut leaves);
leaves
}
fn collect_leaves<'a>(&'a self, leaves: &mut Vec<&'a Vec<LayoutTab>>) {
match self {
Self::Split { a, b, .. } => {
a.collect_leaves(leaves);
b.collect_leaves(leaves);
}
Self::Pane { tabs } => leaves.push(tabs),
}
}
/// Build a pane grid configuration with one empty tab model per leaf.
pub fn configuration<T, F: FnMut() -> T>(
&self,
new_model: &mut F,
) -> pane_grid::Configuration<T> {
match self {
Self::Split { axis, ratio, a, b } => pane_grid::Configuration::Split {
axis: (*axis).into(),
ratio: *ratio,
a: Box::new(a.configuration(new_model)),
b: Box::new(b.configuration(new_model)),
},
Self::Pane { .. } => pane_grid::Configuration::Pane(new_model()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> LayoutNode {
LayoutNode::Split {
axis: LayoutAxis::Vertical,
ratio: 0.6,
a: Box::new(LayoutNode::Pane {
tabs: vec![
LayoutTab {
profile: Some(1),
working_directory: Some(PathBuf::from("/tmp")),
title: Some("build".to_string()),
},
LayoutTab::default(),
],
}),
b: Box::new(LayoutNode::Split {
axis: LayoutAxis::Horizontal,
ratio: 0.5,
a: Box::new(LayoutNode::Pane {
tabs: vec![LayoutTab::default()],
}),
b: Box::new(LayoutNode::Pane {
tabs: vec![LayoutTab::default()],
}),
}),
}
}
#[test]
fn ron_round_trip() {
let layout = sample();
let ron = ron::ser::to_string_pretty(&layout, ron::ser::PrettyConfig::new()).unwrap();
let parsed: LayoutNode = ron::from_str(&ron).unwrap();
let leaves = parsed.leaves();
assert_eq!(leaves.len(), 3);
assert_eq!(leaves[0].len(), 2);
assert_eq!(leaves[0][0].profile, Some(1));
assert_eq!(leaves[0][0].title.as_deref(), Some("build"));
assert_eq!(
leaves[0][0].working_directory.as_deref(),
Some(std::path::Path::new("/tmp"))
);
}
#[test]
fn leaves_depth_first_order() {
let layout = sample();
// Depth-first: pane a first (2 tabs), then the two nested panes
let leaves = layout.leaves();
assert_eq!(
leaves.iter().map(|tabs| tabs.len()).collect::<Vec<_>>(),
vec![2, 1, 1]
);
}
}

View file

@ -58,6 +58,9 @@ mod icon_cache;
use key_bind::key_binds; use key_bind::key_binds;
mod key_bind; mod key_bind;
use layout::{LayoutNode, LayoutTab};
mod layout;
mod shortcuts; mod shortcuts;
mod localize; 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<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 /// Runs application with these settings
#[rustfmt::skip] #[rustfmt::skip]
fn main() -> Result<(), Box<dyn Error>> { fn main() -> Result<(), Box<dyn Error>> {
@ -332,6 +368,8 @@ pub enum Action {
Find, Find,
KeyboardShortcuts, KeyboardShortcuts,
LaunchUrlByMenu, LaunchUrlByMenu,
LayoutLoad,
LayoutSave,
Monitor(MonitorKind), Monitor(MonitorKind),
PaneFocusDown, PaneFocusDown,
PaneFocusLeft, PaneFocusLeft,
@ -347,6 +385,7 @@ pub enum Action {
SaveOutput, SaveOutput,
SelectAll, SelectAll,
Settings, Settings,
SshConnect(usize),
#[cfg(feature = "password_manager")] #[cfg(feature = "password_manager")]
PasswordManager, PasswordManager,
ShowHeaderBar(bool), ShowHeaderBar(bool),
@ -388,6 +427,8 @@ impl Action {
Self::Find => Message::Find(true), Self::Find => Message::Find(true),
Self::KeyboardShortcuts => Message::ToggleContextPage(ContextPage::KeyboardShortcuts), Self::KeyboardShortcuts => Message::ToggleContextPage(ContextPage::KeyboardShortcuts),
Self::LaunchUrlByMenu => Message::LaunchUrlByMenu, Self::LaunchUrlByMenu => Message::LaunchUrlByMenu,
Self::LayoutLoad => Message::LayoutLoad,
Self::LayoutSave => Message::LayoutSave,
Self::Monitor(monitor_kind) => Message::MonitorToggle(*monitor_kind, entity_opt), Self::Monitor(monitor_kind) => Message::MonitorToggle(*monitor_kind, entity_opt),
Self::PaneFocusDown => Message::PaneFocusAdjacent(pane_grid::Direction::Down), Self::PaneFocusDown => Message::PaneFocusAdjacent(pane_grid::Direction::Down),
Self::PaneFocusLeft => Message::PaneFocusAdjacent(pane_grid::Direction::Left), Self::PaneFocusLeft => Message::PaneFocusAdjacent(pane_grid::Direction::Left),
@ -405,6 +446,7 @@ impl Action {
Self::SaveOutput => Message::SaveOutput(entity_opt), Self::SaveOutput => Message::SaveOutput(entity_opt),
Self::SelectAll => Message::SelectAll(entity_opt), Self::SelectAll => Message::SelectAll(entity_opt),
Self::Settings => Message::ToggleContextPage(ContextPage::Settings), Self::Settings => Message::ToggleContextPage(ContextPage::Settings),
Self::SshConnect(index) => Message::SshConnect(*index),
Self::ShowHeaderBar(show_headerbar) => Message::ShowHeaderBar(*show_headerbar), Self::ShowHeaderBar(show_headerbar) => Message::ShowHeaderBar(*show_headerbar),
Self::TabActivate0 => Message::TabActivateJump(0), Self::TabActivate0 => Message::TabActivateJump(0),
Self::TabActivate1 => Message::TabActivateJump(1), Self::TabActivate1 => Message::TabActivateJump(1),
@ -479,6 +521,12 @@ pub enum Message {
Key(Modifiers, Physical, Key), Key(Modifiers, Physical, Key),
LaunchUrl(String), LaunchUrl(String),
LaunchUrlByMenu, LaunchUrlByMenu,
LayoutLoad,
LayoutLoadApply,
LayoutLoadCancel,
LayoutLoadResult(DialogResult),
LayoutSave,
LayoutSaveResult(DialogResult),
Modifiers(Modifiers), Modifiers(Modifiers),
ShortcutCaptureCancel, ShortcutCaptureCancel,
ShortcutCaptureStart(shortcuts::KeyBindAction), ShortcutCaptureStart(shortcuts::KeyBindAction),
@ -522,6 +570,7 @@ pub enum Message {
SelectAll(Option<segmented_button::Entity>), SelectAll(Option<segmented_button::Entity>),
ShowAdvancedFontSettings(bool), ShowAdvancedFontSettings(bool),
ShowHeaderBar(bool), ShowHeaderBar(bool),
SshConnect(usize),
SyntaxTheme(ColorSchemeKind, usize), SyntaxTheme(ColorSchemeKind, usize),
SystemThemeChange, SystemThemeChange,
TabNewInheritWorkingDirectory(bool), TabNewInheritWorkingDirectory(bool),
@ -618,6 +667,8 @@ pub struct App {
color_scheme_rename_id: widget::Id, color_scheme_rename_id: widget::Id,
rename_tab: Option<(segmented_button::Entity, String)>, rename_tab: Option<(segmented_button::Entity, String)>,
rename_tab_id: widget::Id, rename_tab_id: widget::Id,
layout_load_pending: Option<LayoutNode>,
ssh_hosts: Vec<String>,
color_scheme_tab_model: widget::segmented_button::SingleSelectModel, color_scheme_tab_model: widget::segmented_button::SingleSelectModel,
profile_expanded: Option<ProfileId>, profile_expanded: Option<ProfileId>,
show_advanced_font_settings: bool, 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::<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( fn create_and_focus_new_terminal(
&mut self, &mut self,
pane: pane_grid::Pane, pane: pane_grid::Pane,
@ -2049,6 +2191,8 @@ impl Application for App {
color_scheme_rename_id: widget::Id::unique(), color_scheme_rename_id: widget::Id::unique(),
rename_tab: None, rename_tab: None,
rename_tab_id: widget::Id::unique(), rename_tab_id: widget::Id::unique(),
layout_load_pending: None,
ssh_hosts: ssh_config_hosts(),
color_scheme_tab_model: widget::segmented_button::Model::default(), color_scheme_tab_model: widget::segmented_button::Model::default(),
profile_expanded: None, profile_expanded: None,
show_advanced_font_settings: false, 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::<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) => { Message::Modifiers(modifiers) => {
self.modifiers = modifiers; self.modifiers = modifiers;
} }
@ -3085,6 +3301,26 @@ impl Application for App {
return self.update_config(); 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) => { Message::ShowAdvancedFontSettings(show) => {
self.show_advanced_font_settings = show; self.show_advanced_font_settings = show;
} }
@ -3760,6 +3996,21 @@ impl Application for App {
} }
fn dialog(&self) -> Option<Element<'_, Message>> { 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() { if let Some((_, buffer)) = self.rename_tab.as_ref() {
let input = widget::text_input(fl!("tab-name-placeholder"), buffer) let input = widget::text_input(fl!("tab-name-placeholder"), buffer)
.id(self.rename_tab_id.clone()) .id(self.rename_tab_id.clone())
@ -3818,6 +4069,7 @@ impl Application for App {
&self.config, &self.config,
&self.key_binds, &self.key_binds,
self.active_terminal_monitors(), self.active_terminal_monitors(),
&self.ssh_hosts,
)] )]
} }

View file

@ -219,6 +219,7 @@ pub fn menu_bar<'a>(
config: &Config, config: &Config,
key_binds: &HashMap<KeyBind, Action>, key_binds: &HashMap<KeyBind, Action>,
monitors: (bool, bool, bool), monitors: (bool, bool, bool),
ssh_hosts: &[String],
) -> Element<'a, Message> { ) -> Element<'a, Message> {
let mut profile_items = Vec::with_capacity(config.profiles.len()); let mut profile_items = Vec::with_capacity(config.profiles.len());
for (name, id) in config.profile_names() { 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? //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()); let color_scheme_kind = config.color_scheme_kind(core.system_theme());
responsive_menu_bar() responsive_menu_bar()
@ -239,21 +266,7 @@ pub fn menu_bar<'a>(
MENU_ID.clone(), MENU_ID.clone(),
Message::Surface, Message::Surface,
vec![ vec![
( (fl!("file"), file_items),
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!("edit"), fl!("edit"),
vec![ vec![